From 1840b37fe4c70e0561fb09dc16825679aa17b027 Mon Sep 17 00:00:00 2001 From: Andrew Lu Date: Thu, 14 Sep 2023 15:42:03 -0400 Subject: [PATCH] feat: request verifiable presentation from user (#509) (#765) * fix: merge conflicts (#509) * feat: create signing page for verifiable presentations (#509) * feat: sign presentations with metamask (#509) * test: add a test for usePresentVerifiableCredential (#509) * fix: update type names (#509) * test: add tests for new components and hooks (#509) * fix: mark sdk functions with DEV (#509) * fix: pr feedback (#509) * fix: pr feedback (#509) * fix: refactor verifiable presentation signing options into single page, use dropdown menu (#509) * fix: fix tests from updated event names (#509) * fix: update tests (#509) * fix: update styling for verifiable presentation screen (#509) * test: cleanup, fix test coverage (#509) --- packages/app/src/background/contentScript.ts | 15 + packages/app/src/background/cryptKeeper.ts | 21 + .../credentials/__tests__/credentials.test.ts | 141 ++++- .../credentials/__tests__/utils.test.ts | 36 ++ .../background/services/credentials/index.ts | 112 +++- .../background/services/credentials/utils.ts | 31 + packages/app/src/constants/paths.ts | 1 + packages/app/src/types/history/index.ts | 2 + packages/app/src/types/index.ts | 9 +- .../src/types/verifiableCredentials/index.ts | 7 +- .../Item/VerifiableCredentialItem.tsx | 96 ++- .../Item/useVerifiableCredentialItem.ts | 32 +- .../List/VerifiableCredentialList.tsx | 10 + .../__tests__/verifiableCredentials.test.tsx | 110 +++- .../app/src/ui/ducks/verifiableCredentials.ts | 30 +- .../ActivityList/Item/ActivityListItem.tsx | 2 + packages/app/src/ui/pages/Popup/Popup.tsx | 2 + packages/app/src/ui/pages/Popup/usePopup.ts | 1 + .../PresentVerifiableCredential.tsx | 169 ++++++ .../PresentVerifiableCredential.test.tsx | 190 ++++++ .../usePresentVerifiableCredential.test.ts | 548 ++++++++++++++++++ .../PresentVerifiableCredential/index.ts | 3 + .../usePresentVerifiableCredential.ts | 259 +++++++++ packages/demo/index.tsx | 31 +- packages/demo/useCryptKeeper.ts | 87 ++- packages/providers/src/constants/rpcAction.ts | 4 + packages/providers/src/event/types.ts | 4 + .../src/sdk/CryptKeeperInjectedProvider.ts | 35 +- packages/types/src/index.ts | 3 +- .../types/src/verifiableCredentials/index.ts | 226 +------- .../verifiableCredentials.ts | 172 ++++++ .../verifiablePresentations.ts | 63 ++ 32 files changed, 2129 insertions(+), 323 deletions(-) create mode 100644 packages/app/src/ui/pages/PresentVerifiableCredential/PresentVerifiableCredential.tsx create mode 100644 packages/app/src/ui/pages/PresentVerifiableCredential/__tests__/PresentVerifiableCredential.test.tsx create mode 100644 packages/app/src/ui/pages/PresentVerifiableCredential/__tests__/usePresentVerifiableCredential.test.ts create mode 100644 packages/app/src/ui/pages/PresentVerifiableCredential/index.ts create mode 100644 packages/app/src/ui/pages/PresentVerifiableCredential/usePresentVerifiableCredential.ts create mode 100644 packages/types/src/verifiableCredentials/verifiableCredentials.ts create mode 100644 packages/types/src/verifiableCredentials/verifiablePresentations.ts diff --git a/packages/app/src/background/contentScript.ts b/packages/app/src/background/contentScript.ts index 627594579..1b48c3459 100644 --- a/packages/app/src/background/contentScript.ts +++ b/packages/app/src/background/contentScript.ts @@ -11,6 +11,7 @@ import type { ConnectedIdentityMetadata, IRejectedRequest, IMerkleProof, + IVerifiablePresentation, } from "@cryptkeeperzk/types"; function injectScript() { @@ -119,6 +120,20 @@ function injectScript() { ); break; } + case EventName.GENERATE_VERIFIABLE_PRESENTATION: { + window.postMessage( + { + target: "injected-injectedscript", + payload: [ + null, + (action.payload as { verifiablePresentation: IVerifiablePresentation }).verifiablePresentation, + ], + nonce: EventName.GENERATE_VERIFIABLE_PRESENTATION, + }, + "*", + ); + break; + } default: log.warn("unknown action in content script"); } diff --git a/packages/app/src/background/cryptKeeper.ts b/packages/app/src/background/cryptKeeper.ts index 6ee4dbdec..9be425652 100644 --- a/packages/app/src/background/cryptKeeper.ts +++ b/packages/app/src/background/cryptKeeper.ts @@ -39,6 +39,7 @@ const RPC_METHOD_ACCESS: Record = { [RPCAction.GENERATE_SEMAPHORE_PROOF]: true, [RPCAction.GENERATE_RLN_PROOF]: true, [RPCAction.ADD_VERIFIABLE_CREDENTIAL_REQUEST]: true, + [RPCAction.GENERATE_VERIFIABLE_PRESENTATION_REQUEST]: true, [RPCAction.REVEAL_CONNECTED_IDENTITY_COMMITMENT_REQUEST]: true, [RPCAction.JOIN_GROUP_REQUEST]: true, [RPCAction.GENERATE_GROUP_MERKLE_PROOF]: true, @@ -242,6 +243,26 @@ export default class CryptKeeperController { this.lockService.ensure, this.verifiableCredentialsService.deleteAllVerifiableCredentials, ); + this.handler.add( + RPCAction.GENERATE_VERIFIABLE_PRESENTATION, + this.lockService.ensure, + this.verifiableCredentialsService.generateVerifiablePresentation, + ); + this.handler.add( + RPCAction.GENERATE_VERIFIABLE_PRESENTATION_WITH_CRYPTKEEPER, + this.lockService.ensure, + this.verifiableCredentialsService.generateVerifiablePresentationWithCryptkeeper, + ); + this.handler.add( + RPCAction.GENERATE_VERIFIABLE_PRESENTATION_REQUEST, + this.lockService.ensure, + this.verifiableCredentialsService.generateVerifiablePresentationRequest, + ); + this.handler.add( + RPCAction.REJECT_VERIFIABLE_PRESENTATION_REQUEST, + this.lockService.ensure, + this.verifiableCredentialsService.rejectVerifiablePresentationRequest, + ); // Injector this.handler.add(RPCAction.CONNECT, this.injectorService.connect); diff --git a/packages/app/src/background/services/credentials/__tests__/credentials.test.ts b/packages/app/src/background/services/credentials/__tests__/credentials.test.ts index 9bb4b16cd..bb8270799 100644 --- a/packages/app/src/background/services/credentials/__tests__/credentials.test.ts +++ b/packages/app/src/background/services/credentials/__tests__/credentials.test.ts @@ -1,4 +1,6 @@ -import { IVerifiableCredential } from "@cryptkeeperzk/types"; +/* eslint-disable @typescript-eslint/unbound-method */ +import { EventName } from "@cryptkeeperzk/providers"; +import browser from "webextension-polyfill"; import VerifiableCredentialsService from "@src/background/services/credentials"; import { @@ -7,7 +9,14 @@ import { serializeVerifiableCredential, } from "@src/background/services/credentials/utils"; import SimpleStorage from "@src/background/services/storage"; -import { ICryptkeeperVerifiableCredential } from "@src/types"; +import pushMessage from "@src/util/pushMessage"; + +import type { + IVerifiablePresentation, + IVerifiableCredential, + IVerifiablePresentationRequest, +} from "@cryptkeeperzk/types"; +import type { ICryptkeeperVerifiableCredential } from "@src/types"; jest.mock("@src/background/services/crypto", (): unknown => ({ ...jest.requireActual("@src/background/services/crypto"), @@ -19,6 +28,15 @@ jest.mock("@src/background/services/crypto", (): unknown => ({ })), })); +const exampleSignature = "ck-signature"; +jest.mock("@src/background/services/wallet", (): unknown => ({ + getInstance: jest.fn(() => ({ + signMessage: jest.fn(() => Promise.resolve(exampleSignature)), + })), +})); + +jest.mock("@src/util/pushMessage"); + jest.mock("@src/background/services/storage"); interface MockStorage { @@ -86,9 +104,28 @@ describe("background/services/credentials", () => { credentialsMap.set(exampleCredentialHashTwo, exampleCryptkeeperCredentialStringTwo); const credentialsStorageString = JSON.stringify(Array.from(credentialsMap)); + const exampleVerifiablePresentationRequest: IVerifiablePresentationRequest = { + request: "example request", + }; + const exampleVerifiablePresentation: IVerifiablePresentation = { + context: ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiablePresentation"], + verifiableCredential: [exampleCredential], + }; + + const defaultTabs = [{ id: 1 }]; + + const defaultPopupTab = { id: 1, active: true, highlighted: true }; + const verifiableCredentialsService = VerifiableCredentialsService.getInstance(); beforeEach(() => { + (browser.tabs.create as jest.Mock).mockResolvedValue(defaultPopupTab); + + (browser.tabs.query as jest.Mock).mockResolvedValue(defaultTabs); + + (browser.tabs.sendMessage as jest.Mock).mockRejectedValueOnce(false).mockResolvedValue(true); + (SimpleStorage as jest.Mock).mock.instances.forEach((instance: MockStorage) => { instance.get.mockReturnValue(credentialsStorageString); instance.set.mockReturnValue(undefined); @@ -102,6 +139,106 @@ describe("background/services/credentials", () => { instance.set.mockClear(); instance.clear.mockClear(); }); + + (pushMessage as jest.Mock).mockClear(); + + (browser.tabs.sendMessage as jest.Mock).mockClear(); + }); + + describe("add and reject verifiable credential requests", () => { + test("should successfully create an add verifiable credential request", async () => { + await verifiableCredentialsService.addVerifiableCredentialRequest(exampleCredentialString); + + expect(browser.tabs.query).toBeCalledWith({ lastFocusedWindow: true }); + + const defaultOptions = { + tabId: defaultPopupTab.id, + type: "popup", + focused: true, + width: 385, + height: 610, + }; + + expect(browser.windows.create).toBeCalledWith(defaultOptions); + }); + + test("should successfully reject a verifiable credential request", async () => { + await verifiableCredentialsService.rejectVerifiableCredentialRequest(); + + expect(browser.tabs.query).toBeCalledWith({ lastFocusedWindow: true }); + expect(browser.tabs.sendMessage).toBeCalledWith(defaultTabs[0].id, { + type: EventName.USER_REJECT, + payload: { type: EventName.ADD_VERIFIABLE_CREDENTIAL }, + }); + }); + }); + + describe("generate verifiable presentations", () => { + test("should successfully create a generate verifiable presentation request", async () => { + await verifiableCredentialsService.generateVerifiablePresentationRequest(exampleVerifiablePresentationRequest); + + expect(browser.tabs.query).toBeCalledWith({ lastFocusedWindow: true }); + + const defaultOptions = { + tabId: defaultPopupTab.id, + type: "popup", + focused: true, + width: 385, + height: 610, + }; + + expect(browser.windows.create).toBeCalledWith(defaultOptions); + }); + + test("should successfully reject a verifiable presentation request", async () => { + await verifiableCredentialsService.rejectVerifiablePresentationRequest(); + + expect(browser.tabs.query).toBeCalledWith({ lastFocusedWindow: true }); + expect(browser.tabs.sendMessage).toBeCalledWith(defaultTabs[0].id, { + type: EventName.USER_REJECT, + payload: { type: EventName.VERIFIABLE_PRESENTATION_REQUEST }, + }); + }); + + test("should successfully generate a verifiable presentation", async () => { + await verifiableCredentialsService.generateVerifiablePresentation(exampleVerifiablePresentation); + + expect(browser.tabs.query).toBeCalledWith({ lastFocusedWindow: true }); + expect(browser.tabs.sendMessage).toBeCalledWith(defaultTabs[0].id, { + type: EventName.GENERATE_VERIFIABLE_PRESENTATION, + payload: { verifiablePresentation: exampleVerifiablePresentation }, + }); + }); + + test("should successfully generate a verifiable presentation with cryptkeeper", async () => { + const exampleAddress = "0x123"; + const ETHEREUM_SIGNATURE_SPECIFICATION_TYPE = "EthereumEip712Signature2021"; + const VERIFIABLE_CREDENTIAL_PROOF_PURPOSE = "assertionMethod"; + + await verifiableCredentialsService.generateVerifiablePresentationWithCryptkeeper({ + verifiablePresentation: exampleVerifiablePresentation, + address: exampleAddress, + }); + + const signedVerifiablePresentation = { + ...exampleVerifiablePresentation, + proof: [ + { + type: [ETHEREUM_SIGNATURE_SPECIFICATION_TYPE], + proofPurpose: VERIFIABLE_CREDENTIAL_PROOF_PURPOSE, + verificationMethod: exampleAddress, + created: new Date(), + proofValue: exampleSignature, + }, + ], + }; + + expect(browser.tabs.query).toBeCalledWith({ lastFocusedWindow: true }); + expect(browser.tabs.sendMessage).toBeCalledWith(defaultTabs[0].id, { + type: EventName.GENERATE_VERIFIABLE_PRESENTATION, + payload: { verifiablePresentation: signedVerifiablePresentation }, + }); + }); }); describe("add and retrieve verifiable credentials", () => { diff --git a/packages/app/src/background/services/credentials/__tests__/utils.test.ts b/packages/app/src/background/services/credentials/__tests__/utils.test.ts index deb084640..61bb1c990 100644 --- a/packages/app/src/background/services/credentials/__tests__/utils.test.ts +++ b/packages/app/src/background/services/credentials/__tests__/utils.test.ts @@ -9,6 +9,7 @@ import { validateSerializedVerifiableCredential, generateInitialMetadataForVerifiableCredential, hashVerifiableCredential, + serializeVerifiablePresentation, } from "../utils"; describe("util/serializeCryptkeeperVerifiableCredential", () => { @@ -42,6 +43,41 @@ describe("util/serializeCryptkeeperVerifiableCredential", () => { }); }); +describe("util/serializeVerifiablePresentation", () => { + test("should serialize a verifiable presentation correctly", () => { + const rawCredential = { + context: ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiableCredential"], + issuer: "did:ethr:0x123", + issuanceDate: new Date("2010-01-01T19:23:24Z"), + credentialSubject: { + id: "did:ethr:0x123", + claims: { + name: "John Doe", + }, + }, + }; + const verifiablePresentation = { + context: ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiablePresentation"], + verifiableCredential: [rawCredential], + }; + + expect(serializeVerifiablePresentation(verifiablePresentation)).toStrictEqual( + stringify({ ...verifiablePresentation, verifiableCredential: [serializeVerifiableCredential(rawCredential)] }), + ); + }); + + test("should serialize a verifiable presentation with no credentials correctly", () => { + const verifiablePresentation = { + context: ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiablePresentation"], + }; + + expect(serializeVerifiablePresentation(verifiablePresentation)).toStrictEqual(stringify(verifiablePresentation)); + }); +}); + describe("util/deserializeVerifiableCredential", () => { test("should deserialize a verifiable credential correctly", async () => { const rawCredential = { diff --git a/packages/app/src/background/services/credentials/index.ts b/packages/app/src/background/services/credentials/index.ts index 279c3471d..c09a26215 100644 --- a/packages/app/src/background/services/credentials/index.ts +++ b/packages/app/src/background/services/credentials/index.ts @@ -6,11 +6,16 @@ import CryptoService, { ECryptMode } from "@src/background/services/crypto"; import HistoryService from "@src/background/services/history"; import NotificationService from "@src/background/services/notification"; import SimpleStorage from "@src/background/services/storage"; +import WalletService from "@src/background/services/wallet"; import { Paths } from "@src/constants"; import { OperationType, IRenameVerifiableCredentialArgs, ICryptkeeperVerifiableCredential } from "@src/types"; -import { IAddVerifiableCredentialArgs } from "@src/types/verifiableCredentials"; +import type { IVerifiablePresentation, IVerifiablePresentationRequest } from "@cryptkeeperzk/types"; import type { BackupData, IBackupable } from "@src/background/services/backup"; +import type { + IAddVerifiableCredentialArgs, + IGenerateVerifiablePresentationWithCryptkeeperArgs, +} from "@src/types/verifiableCredentials"; import { generateInitialMetadataForVerifiableCredential, @@ -18,9 +23,12 @@ import { deserializeVerifiableCredential, deserializeCryptkeeperVerifiableCredential, validateSerializedVerifiableCredential, + serializeVerifiablePresentation, } from "./utils"; const VERIFIABLE_CREDENTIALS_KEY = "@@VERIFIABLE-CREDENTIALS@@"; +const ETHEREUM_SIGNATURE_SPECIFICATION_TYPE = "EthereumEip712Signature2021"; +const VERIFIABLE_CREDENTIAL_PROOF_PURPOSE = "assertionMethod"; export default class VerifiableCredentialsService implements IBackupable { private static INSTANCE?: VerifiableCredentialsService; @@ -29,6 +37,8 @@ export default class VerifiableCredentialsService implements IBackupable { private cryptoService: CryptoService; + private walletService: WalletService; + private historyService: HistoryService; private notificationService: NotificationService; @@ -38,6 +48,7 @@ export default class VerifiableCredentialsService implements IBackupable { private constructor() { this.verifiableCredentialsStore = new SimpleStorage(VERIFIABLE_CREDENTIALS_KEY); this.cryptoService = CryptoService.getInstance(); + this.walletService = WalletService.getInstance(); this.historyService = HistoryService.getInstance(); this.notificationService = NotificationService.getInstance(); this.browserController = BrowserUtils.getInstance(); @@ -200,6 +211,105 @@ export default class VerifiableCredentialsService implements IBackupable { }); }; + generateVerifiablePresentationRequest = async ({ request }: IVerifiablePresentationRequest): Promise => { + await this.browserController.openPopup({ + params: { + redirect: Paths.GENERATE_VERIFIABLE_PRESENTATION_REQUEST, + request, + }, + }); + }; + + generateVerifiablePresentation = async (verifiablePresentation: IVerifiablePresentation): Promise => { + await this.historyService.trackOperation(OperationType.GENERATE_VERIFIABLE_PRESENTATION, {}); + await this.notificationService.create({ + options: { + title: "Verifiable Presentation generated.", + message: `Generated 1 Verifiable Presentation.`, + iconUrl: browser.runtime.getURL("/icons/logo.png"), + type: "basic", + }, + }); + const tabs = await browser.tabs.query({ active: true }); + await Promise.all( + tabs.map((tab) => + browser.tabs + .sendMessage(tab.id!, { + type: EventName.GENERATE_VERIFIABLE_PRESENTATION, + payload: { verifiablePresentation }, + }) + .catch(() => undefined), + ), + ); + }; + + generateVerifiablePresentationWithCryptkeeper = async ({ + verifiablePresentation, + address, + }: IGenerateVerifiablePresentationWithCryptkeeperArgs): Promise => { + const serializedVerifiablePresentation = serializeVerifiablePresentation(verifiablePresentation); + const signature = await this.walletService.signMessage({ + message: serializedVerifiablePresentation, + address, + }); + const signedVerifiablePresentation = { + ...verifiablePresentation, + proof: [ + { + type: [ETHEREUM_SIGNATURE_SPECIFICATION_TYPE], + proofPurpose: VERIFIABLE_CREDENTIAL_PROOF_PURPOSE, + verificationMethod: address, + created: new Date(), + proofValue: signature, + }, + ], + }; + + await this.historyService.trackOperation(OperationType.GENERATE_VERIFIABLE_PRESENTATION, {}); + await this.notificationService.create({ + options: { + title: "Verifiable Presentation generated.", + message: `Generated 1 Verifiable Presentation.`, + iconUrl: browser.runtime.getURL("/icons/logo.png"), + type: "basic", + }, + }); + const tabs = await browser.tabs.query({ active: true }); + await Promise.all( + tabs.map((tab) => + browser.tabs + .sendMessage(tab.id!, { + type: EventName.GENERATE_VERIFIABLE_PRESENTATION, + payload: { verifiablePresentation: signedVerifiablePresentation }, + }) + .catch(() => undefined), + ), + ); + }; + + rejectVerifiablePresentationRequest = async (): Promise => { + await this.historyService.trackOperation(OperationType.REJECT_VERIFIABLE_PRESENTATION_REQUEST, {}); + await this.notificationService.create({ + options: { + title: "Request to generate Verifiable Presentation rejected", + message: `Rejected a request to generate 1 Verifiable Presentation.`, + iconUrl: browser.runtime.getURL("/icons/logo.png"), + type: "basic", + }, + }); + const tabs = await browser.tabs.query({ active: true }); + await Promise.all( + tabs.map((tab) => + browser.tabs + .sendMessage(tab.id!, { + type: EventName.USER_REJECT, + payload: { type: EventName.VERIFIABLE_PRESENTATION_REQUEST }, + }) + .catch(() => undefined), + ), + ); + }; + private insertCryptkeeperVerifiableCredentialIntoStore = async ( cryptkeeperVerifiableCredential: ICryptkeeperVerifiableCredential, ): Promise => { diff --git a/packages/app/src/background/services/credentials/utils.ts b/packages/app/src/background/services/credentials/utils.ts index 61aa198cd..67788c313 100644 --- a/packages/app/src/background/services/credentials/utils.ts +++ b/packages/app/src/background/services/credentials/utils.ts @@ -5,6 +5,7 @@ import { ICredentialSubject, ICredentialStatus, ClaimValue, + IVerifiablePresentation, } from "@cryptkeeperzk/types"; import { SHA256 } from "crypto-js"; import stringify from "json-stable-stringify"; @@ -174,6 +175,26 @@ export async function deserializeVerifiableCredential( return verifiableCredentialSchema.validate(deserializedVerifiableCredential); } +/** + * Serializes a VerifiablePresentation object into a JSON string. + * @param verifiablePresentation An object representing a VerifiablePresentation. + * @returns A string representing a VerifiablePresentation. + */ +export function serializeVerifiablePresentation(verifiablePresentation: IVerifiablePresentation): string { + if (!verifiablePresentation.verifiableCredential) { + return stringify(verifiablePresentation); + } + + const serializedVerifiableCredentials = verifiablePresentation.verifiableCredential.map((verifiableCredential) => + serializeVerifiableCredential(verifiableCredential), + ); + + return stringify({ + ...verifiablePresentation, + verifiableCredential: serializedVerifiableCredentials, + }); +} + /** * Determines if a string represents a valid VerifiableCredential. * @param serializedVerifiableCredential An string representing a VerifiableCredential. @@ -209,6 +230,16 @@ export function generateInitialMetadataForVerifiableCredential( }; } +export function generateVerifiablePresentationFromVerifiableCredentials( + verifiableCredentials: IVerifiableCredential[], +): IVerifiablePresentation { + return { + context: ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiablePresentation"], + verifiableCredential: verifiableCredentials, + }; +} + function parseDate(_: string, originalValue: string): Date | string { const date = new Date(originalValue); return Number.isNaN(date.getTime()) ? originalValue : date; diff --git a/packages/app/src/constants/paths.ts b/packages/app/src/constants/paths.ts index c2e194405..e65a16420 100644 --- a/packages/app/src/constants/paths.ts +++ b/packages/app/src/constants/paths.ts @@ -17,6 +17,7 @@ export enum Paths { RECOVER = "/recover", RESET_PASSWORD = "/reset-password", ADD_VERIFIABLE_CREDENTIAL = "/add-verifiable-credential", + GENERATE_VERIFIABLE_PRESENTATION_REQUEST = "/generate-verifiable-presentation-request", JOIN_GROUP = "/groups/:id/join", GROUP_MERKLE_PROOF = "/groups/:id/merkle-proof", } diff --git a/packages/app/src/types/history/index.ts b/packages/app/src/types/history/index.ts index ff4489127..7b8abc2bc 100644 --- a/packages/app/src/types/history/index.ts +++ b/packages/app/src/types/history/index.ts @@ -12,6 +12,8 @@ export enum OperationType { DELETE_VERIFIABLE_CREDENTIAL = "DELETE_VERIFIABLE_CREDENTIAL", DELETE_ALL_VERIFIABLE_CREDENTIALS = "DELETE_ALL_VERIFIABLE_CREDENTIALS", REJECT_VERIFIABLE_CREDENTIAL_REQUEST = "REJECT_VERIFIABLE_CREDENTIAL_REQUEST", + GENERATE_VERIFIABLE_PRESENTATION = "GENERATE_VERIFIABLE_PRESENTATION", + REJECT_VERIFIABLE_PRESENTATION_REQUEST = "REJECT_VERIFIABLE_PRESENTATION_REQUEST", REVEAL_IDENTITY_COMMITMENT = "REVEAL_IDENTITY_COMMITMENT", JOIN_GROUP = "JOIN_GROUP", } diff --git a/packages/app/src/types/index.ts b/packages/app/src/types/index.ts index 748c2671a..984eaa568 100644 --- a/packages/app/src/types/index.ts +++ b/packages/app/src/types/index.ts @@ -6,9 +6,10 @@ export { ConnectorNames, type IUseWalletData } from "./hooks"; export { type ISecretArgs, type ICheckPasswordArgs } from "./lock"; export { InitializationStep } from "./misc"; export { type DeferredPromise } from "./utility"; -export { - type IVerifiableCredentialMetadata, - type ICryptkeeperVerifiableCredential, - type IRenameVerifiableCredentialArgs, +export type { + IVerifiableCredentialMetadata, + ICryptkeeperVerifiableCredential, + IRenameVerifiableCredentialArgs, + IGenerateVerifiablePresentationWithCryptkeeperArgs, } from "./verifiableCredentials"; export { type ISignMessageArgs, type ICheckMnemonicArgs } from "./wallet"; diff --git a/packages/app/src/types/verifiableCredentials/index.ts b/packages/app/src/types/verifiableCredentials/index.ts index 6aa89ce4d..700f356c0 100644 --- a/packages/app/src/types/verifiableCredentials/index.ts +++ b/packages/app/src/types/verifiableCredentials/index.ts @@ -1,4 +1,4 @@ -import { IVerifiableCredential } from "@cryptkeeperzk/types"; +import type { IVerifiableCredential, IVerifiablePresentation } from "@cryptkeeperzk/types"; export interface IVerifiableCredentialMetadata { name: string; @@ -19,3 +19,8 @@ export interface IRenameVerifiableCredentialArgs { verifiableCredentialHash: string; newVerifiableCredentialName: string; } + +export interface IGenerateVerifiablePresentationWithCryptkeeperArgs { + verifiablePresentation: IVerifiablePresentation; + address: string; +} diff --git a/packages/app/src/ui/components/VerifiableCredential/Item/VerifiableCredentialItem.tsx b/packages/app/src/ui/components/VerifiableCredential/Item/VerifiableCredentialItem.tsx index fb5d9aee1..6067cae35 100644 --- a/packages/app/src/ui/components/VerifiableCredential/Item/VerifiableCredentialItem.tsx +++ b/packages/app/src/ui/components/VerifiableCredential/Item/VerifiableCredentialItem.tsx @@ -1,5 +1,7 @@ import { IVerifiableCredential } from "@cryptkeeperzk/types"; import CheckIcon from "@mui/icons-material/Check"; +import CheckCircleIcon from "@mui/icons-material/CheckCircle"; +import CheckCircleOutlineIcon from "@mui/icons-material/CheckCircleOutline"; import MoreHorizIcon from "@mui/icons-material/MoreHoriz"; import Box from "@mui/material/Box"; import IconButton from "@mui/material/IconButton"; @@ -15,21 +17,27 @@ import { useVerifiableCredentialItem } from "./useVerifiableCredentialItem"; export interface VerifiableCredentialItemProps { verifiableCredential: IVerifiableCredential; metadata: IVerifiableCredentialMetadata; - onRenameVerifiableCredential: (hash: string, name: string) => Promise; - onDeleteVerifiableCredential: (hash: string) => Promise; + selected?: boolean; + onRenameVerifiableCredential?: (hash: string, name: string) => Promise; + onDeleteVerifiableCredential?: (hash: string) => Promise; + onToggleSelectVerifiableCredential?: (hash: string) => void; } export const VerifiableCredentialItem = ({ verifiableCredential, metadata, - onRenameVerifiableCredential, - onDeleteVerifiableCredential, + selected = undefined, + onRenameVerifiableCredential = undefined, + onDeleteVerifiableCredential = undefined, + onToggleSelectVerifiableCredential = undefined, }: VerifiableCredentialItemProps): JSX.Element => { - const { isRenaming, name, register, onSubmit, onToggleRenaming, onDelete } = useVerifiableCredentialItem({ - metadata, - onRename: onRenameVerifiableCredential, - onDelete: onDeleteVerifiableCredential, - }); + const { isRenaming, name, register, onSubmit, onToggleRenaming, onDelete, onToggleSelect } = + useVerifiableCredentialItem({ + metadata, + onRename: onRenameVerifiableCredential, + onDelete: onDeleteVerifiableCredential, + onSelect: onToggleSelectVerifiableCredential, + }); const menuItems = [ { label: "Rename", isDangerItem: false, onClick: onToggleRenaming }, @@ -41,21 +49,53 @@ export const VerifiableCredentialItem = ({ ? verifiableCredential.issuer : verifiableCredential.issuer.id || "unknown"; + const isSelectorEnabled = selected !== undefined; + + const isMenuEnabled = onRenameVerifiableCredential !== undefined && onDeleteVerifiableCredential !== undefined; + return ( + {isSelectorEnabled && + (selected ? ( + + + + ) : ( + + + + ))} + @@ -95,23 +135,31 @@ export const VerifiableCredentialItem = ({ ) : ( - + {name} - + )} - - Credential hash: {ellipsify(metadata.hash)} - + Credential hash: {ellipsify(metadata.hash)} - + Issuer: {ellipsify(issuer)} - - - + {isMenuEnabled && ( + + + + )} ); }; diff --git a/packages/app/src/ui/components/VerifiableCredential/Item/useVerifiableCredentialItem.ts b/packages/app/src/ui/components/VerifiableCredential/Item/useVerifiableCredentialItem.ts index 6ef7eeb36..b630869e4 100644 --- a/packages/app/src/ui/components/VerifiableCredential/Item/useVerifiableCredentialItem.ts +++ b/packages/app/src/ui/components/VerifiableCredential/Item/useVerifiableCredentialItem.ts @@ -1,8 +1,7 @@ import { useCallback, useState, FormEvent as ReactFormEvent } from "react"; import { UseFormRegister, useForm } from "react-hook-form"; -import { IVerifiableCredentialMetadata } from "@src/types"; -import { useAppDispatch } from "@src/ui/ducks/hooks"; +import type { IVerifiableCredentialMetadata } from "@src/types"; export interface RenameVerifiableCredentialItemData { name: string; @@ -15,22 +14,22 @@ export interface IUseVerifiableCredentialItemData { onSubmit: (event: ReactFormEvent) => void; onToggleRenaming: () => void; onDelete: () => Promise; + onToggleSelect: () => void; } export interface UseVerifiableCredentialItemArgs { metadata: IVerifiableCredentialMetadata; - onRename: (hash: string, name: string) => Promise; - onDelete: (hash: string) => Promise; + onRename?: (hash: string, name: string) => Promise; + onDelete?: (hash: string) => Promise; + onSelect?: (hash: string) => void; } export const useVerifiableCredentialItem = ( useVerifiableCredentialItemArgs: UseVerifiableCredentialItemArgs, ): IUseVerifiableCredentialItemData => { - const { metadata, onRename, onDelete } = useVerifiableCredentialItemArgs; + const { metadata, onRename, onDelete, onSelect } = useVerifiableCredentialItemArgs; const { hash, name: initialName } = metadata; - const dispatch = useAppDispatch(); - const { register, watch } = useForm({ defaultValues: { name: initialName, @@ -48,15 +47,25 @@ export const useVerifiableCredentialItem = ( const onSubmit = useCallback( async (event: ReactFormEvent) => { event.preventDefault(); - await onRename(hash, name); - setIsRenaming(false); + if (onRename) { + await onRename(hash, name); + setIsRenaming(false); + } }, [onRename, name, setIsRenaming], ); const onDeleteVerifiableCredential = useCallback(async () => { - await onDelete(hash); - }, [onDelete, dispatch]); + if (onDelete) { + await onDelete(hash); + } + }, [onDelete, hash]); + + const onToggleSelect = useCallback(() => { + if (onSelect) { + onSelect(hash); + } + }, [onSelect, hash]); return { isRenaming, @@ -65,5 +74,6 @@ export const useVerifiableCredentialItem = ( onSubmit, onToggleRenaming, onDelete: onDeleteVerifiableCredential, + onToggleSelect, }; }; diff --git a/packages/app/src/ui/components/VerifiableCredential/List/VerifiableCredentialList.tsx b/packages/app/src/ui/components/VerifiableCredential/List/VerifiableCredentialList.tsx index 6d7958c52..6d1156e5d 100644 --- a/packages/app/src/ui/components/VerifiableCredential/List/VerifiableCredentialList.tsx +++ b/packages/app/src/ui/components/VerifiableCredential/List/VerifiableCredentialList.tsx @@ -12,10 +12,20 @@ export const VerifiableCredentialList = (): JSX.Element => { return ( {cryptkeeperVerifiableCredentials.map(({ verifiableCredential, metadata }) => ( diff --git a/packages/app/src/ui/ducks/__tests__/verifiableCredentials.test.tsx b/packages/app/src/ui/ducks/__tests__/verifiableCredentials.test.tsx index 7770fc66c..63096017c 100644 --- a/packages/app/src/ui/ducks/__tests__/verifiableCredentials.test.tsx +++ b/packages/app/src/ui/ducks/__tests__/verifiableCredentials.test.tsx @@ -17,47 +17,52 @@ import { deleteVerifiableCredential, useVerifiableCredentials, fetchVerifiableCredentials, + generateVerifiablePresentation, + generateVerifiablePresentationWithCryptkeeper, + rejectVerifiablePresentationRequest, } from "../verifiableCredentials"; jest.unmock("@src/ui/ducks/hooks"); describe("ui/ducks/verifiableCredentials", () => { + const mockVerifiableCredentialOne = { + context: ["https://www.w3.org/2018/credentials/v1"], + id: "http://example.edu/credentials/3732", + type: ["VerifiableCredential"], + issuer: "did:example:123", + issuanceDate: new Date("2020-03-10T04:24:12.164Z"), + credentialSubject: { + id: "did:example:456", + claims: { + type: "BachelorDegree", + name: "Bachelor of Science and Arts", + }, + }, + }; + const mockVerifiableCredentialTwo = { + context: ["https://www.w3.org/2018/credentials/v1"], + id: "http://example.edu/credentials/3733", + type: ["VerifiableCredential"], + issuer: "did:example:12345", + issuanceDate: new Date("2020-03-10T04:24:12.164Z"), + credentialSubject: { + id: "did:example:123", + claims: { + type: "BachelorDegree", + name: "Bachelor of Science and Arts", + }, + }, + }; const mockCryptkeeperVerifiableCredentials = [ { - verifiableCredential: { - context: ["https://www.w3.org/2018/credentials/v1"], - id: "http://example.edu/credentials/3732", - type: ["VerifiableCredential"], - issuer: "did:example:123", - issuanceDate: new Date("2020-03-10T04:24:12.164Z"), - credentialSubject: { - id: "did:example:456", - claims: { - type: "BachelorDegree", - name: "Bachelor of Science and Arts", - }, - }, - }, + verifiableCredential: mockVerifiableCredentialOne, metadata: { hash: "0x123", name: "Credential #0", }, }, { - verifiableCredential: { - context: ["https://www.w3.org/2018/credentials/v1"], - id: "http://example.edu/credentials/3733", - type: ["VerifiableCredential"], - issuer: "did:example:12345", - issuanceDate: new Date("2020-03-10T04:24:12.164Z"), - credentialSubject: { - id: "did:example:123", - claims: { - type: "BachelorDegree", - name: "Bachelor of Science and Arts", - }, - }, - }, + verifiableCredential: mockVerifiableCredentialTwo, metadata: { hash: "0x1234", name: "Credential #1", @@ -65,6 +70,12 @@ describe("ui/ducks/verifiableCredentials", () => { }, ]; + const mockVerifiablePresentation = { + context: ["https://www.w3.org/2018/credentials/v1"], + type: ["VerifiablePresentation"], + verifiableCredential: [mockVerifiableCredentialOne, mockVerifiableCredentialTwo], + }; + const mockSerializedVerifiableCredentials = [ serializeCryptkeeperVerifiableCredential(mockCryptkeeperVerifiableCredentials[0]), serializeCryptkeeperVerifiableCredential(mockCryptkeeperVerifiableCredentials[1]), @@ -104,6 +115,15 @@ describe("ui/ducks/verifiableCredentials", () => { }); }); + test("should reject verifiable credential request properly", async () => { + await Promise.resolve(store.dispatch(rejectVerifiableCredentialRequest())); + + expect(postMessage).toBeCalledTimes(1); + expect(postMessage).toBeCalledWith({ + method: RPCAction.REJECT_VERIFIABLE_CREDENTIAL_REQUEST, + }); + }); + test("should rename verifiable credential properly", async () => { const mockPayload = { verifiableCredentialHash: "hash", newVerifiableCredentialName: "name" }; @@ -128,12 +148,40 @@ describe("ui/ducks/verifiableCredentials", () => { }); }); - test("should reject verifiable credential request properly", async () => { - await Promise.resolve(store.dispatch(rejectVerifiableCredentialRequest())); + test("should generate verfifiable presentation properly", async () => { + await Promise.resolve(store.dispatch(generateVerifiablePresentation(mockVerifiablePresentation))); expect(postMessage).toBeCalledTimes(1); expect(postMessage).toBeCalledWith({ - method: RPCAction.REJECT_VERIFIABLE_CREDENTIAL_REQUEST, + method: RPCAction.GENERATE_VERIFIABLE_PRESENTATION, + payload: mockVerifiablePresentation, + }); + }); + + test("should generate verifiable presentation with cryptkeeper properly", async () => { + const mockAddress = "0x123"; + await Promise.resolve( + store.dispatch( + generateVerifiablePresentationWithCryptkeeper({ + verifiablePresentation: mockVerifiablePresentation, + address: mockAddress, + }), + ), + ); + + expect(postMessage).toBeCalledTimes(1); + expect(postMessage).toBeCalledWith({ + method: RPCAction.GENERATE_VERIFIABLE_PRESENTATION_WITH_CRYPTKEEPER, + payload: { verifiablePresentation: mockVerifiablePresentation, address: mockAddress }, + }); + }); + + test("should reject a verfifiable presentation request properly", async () => { + await Promise.resolve(store.dispatch(rejectVerifiablePresentationRequest())); + + expect(postMessage).toBeCalledTimes(1); + expect(postMessage).toBeCalledWith({ + method: RPCAction.REJECT_VERIFIABLE_PRESENTATION_REQUEST, }); }); }); diff --git a/packages/app/src/ui/ducks/verifiableCredentials.ts b/packages/app/src/ui/ducks/verifiableCredentials.ts index 01482b906..69ec725a0 100644 --- a/packages/app/src/ui/ducks/verifiableCredentials.ts +++ b/packages/app/src/ui/ducks/verifiableCredentials.ts @@ -3,9 +3,14 @@ import { RPCAction } from "@cryptkeeperzk/providers"; import { createSlice, PayloadAction } from "@reduxjs/toolkit"; import { serializeCryptkeeperVerifiableCredential } from "@src/background/services/credentials/utils"; -import { ICryptkeeperVerifiableCredential, IRenameVerifiableCredentialArgs } from "@src/types"; import postMessage from "@src/util/postMessage"; +import type { IVerifiablePresentation } from "@cryptkeeperzk/types"; +import type { + ICryptkeeperVerifiableCredential, + IGenerateVerifiablePresentationWithCryptkeeperArgs, + IRenameVerifiableCredentialArgs, +} from "@src/types"; import type { TypedThunk } from "@src/ui/store/configureAppStore"; import { useAppSelector } from "./hooks"; @@ -61,6 +66,29 @@ export const deleteVerifiableCredential = (verifiableCredentialHash: string) => }); }; +export const generateVerifiablePresentation = + (verifiablePresentation: IVerifiablePresentation) => async (): Promise => { + await postMessage({ + method: RPCAction.GENERATE_VERIFIABLE_PRESENTATION, + payload: verifiablePresentation, + }); + }; + +export const generateVerifiablePresentationWithCryptkeeper = + (generateVerifiablePresentationArgs: IGenerateVerifiablePresentationWithCryptkeeperArgs) => + async (): Promise => { + await postMessage({ + method: RPCAction.GENERATE_VERIFIABLE_PRESENTATION_WITH_CRYPTKEEPER, + payload: generateVerifiablePresentationArgs, + }); + }; + +export const rejectVerifiablePresentationRequest = () => async (): Promise => { + await postMessage({ + method: RPCAction.REJECT_VERIFIABLE_PRESENTATION_REQUEST, + }); +}; + export const fetchVerifiableCredentials = (): TypedThunk => async (dispatch) => { const cryptkeeperVerifiableCredentials = await postMessage({ method: RPCAction.GET_ALL_VERIFIABLE_CREDENTIALS, diff --git a/packages/app/src/ui/pages/Home/components/ActivityList/Item/ActivityListItem.tsx b/packages/app/src/ui/pages/Home/components/ActivityList/Item/ActivityListItem.tsx index db5b69650..d647dd9e0 100644 --- a/packages/app/src/ui/pages/Home/components/ActivityList/Item/ActivityListItem.tsx +++ b/packages/app/src/ui/pages/Home/components/ActivityList/Item/ActivityListItem.tsx @@ -33,6 +33,8 @@ const OPERATIONS: Record = { [OperationType.DELETE_VERIFIABLE_CREDENTIAL]: "Verifiable credential deleted", [OperationType.DELETE_ALL_VERIFIABLE_CREDENTIALS]: "All verifiable credentials deleted", [OperationType.REJECT_VERIFIABLE_CREDENTIAL_REQUEST]: "Verifiable credential request rejected", + [OperationType.GENERATE_VERIFIABLE_PRESENTATION]: "Verifiable presentation generated", + [OperationType.REJECT_VERIFIABLE_PRESENTATION_REQUEST]: "Verifiable presentation request rejected", [OperationType.REVEAL_IDENTITY_COMMITMENT]: "Identity revealed", [OperationType.JOIN_GROUP]: "Joined group", }; diff --git a/packages/app/src/ui/pages/Popup/Popup.tsx b/packages/app/src/ui/pages/Popup/Popup.tsx index cdde15c83..8d6011ee1 100644 --- a/packages/app/src/ui/pages/Popup/Popup.tsx +++ b/packages/app/src/ui/pages/Popup/Popup.tsx @@ -14,6 +14,7 @@ import JoinGroup from "@src/ui/pages/JoinGroup"; import Login from "@src/ui/pages/Login"; import Onboarding from "@src/ui/pages/Onboarding"; import OnboardingBackup from "@src/ui/pages/OnboardingBackup"; +import PresentVerifiableCredential from "@src/ui/pages/PresentVerifiableCredential"; import Recover from "@src/ui/pages/Recover"; import ResetPassword from "@src/ui/pages/ResetPassword"; import RevealIdentityCommitment from "@src/ui/pages/RevealIdentityCommitment"; @@ -43,6 +44,7 @@ const routeConfig: RouteObject[] = [ { path: Paths.RECOVER, element: }, { path: Paths.RESET_PASSWORD, element: }, { path: Paths.ADD_VERIFIABLE_CREDENTIAL, element: }, + { path: Paths.GENERATE_VERIFIABLE_PRESENTATION_REQUEST, element: }, { path: Paths.JOIN_GROUP, element: }, { path: Paths.GROUP_MERKLE_PROOF, element: }, { diff --git a/packages/app/src/ui/pages/Popup/usePopup.ts b/packages/app/src/ui/pages/Popup/usePopup.ts index 4f1daa1ad..97bfdcbe4 100644 --- a/packages/app/src/ui/pages/Popup/usePopup.ts +++ b/packages/app/src/ui/pages/Popup/usePopup.ts @@ -21,6 +21,7 @@ const REDIRECT_PATHS: Record = { [Paths.UPLOAD_BACKUP]: Paths.UPLOAD_BACKUP, [Paths.ONBOARDING_BACKUP]: Paths.ONBOARDING_BACKUP, [Paths.ADD_VERIFIABLE_CREDENTIAL]: Paths.ADD_VERIFIABLE_CREDENTIAL, + [Paths.GENERATE_VERIFIABLE_PRESENTATION_REQUEST]: Paths.GENERATE_VERIFIABLE_PRESENTATION_REQUEST, [Paths.JOIN_GROUP]: Paths.JOIN_GROUP, [Paths.GROUP_MERKLE_PROOF]: Paths.GROUP_MERKLE_PROOF, }; diff --git a/packages/app/src/ui/pages/PresentVerifiableCredential/PresentVerifiableCredential.tsx b/packages/app/src/ui/pages/PresentVerifiableCredential/PresentVerifiableCredential.tsx new file mode 100644 index 000000000..d1b35fd27 --- /dev/null +++ b/packages/app/src/ui/pages/PresentVerifiableCredential/PresentVerifiableCredential.tsx @@ -0,0 +1,169 @@ +import ArrowDropDownIcon from "@mui/icons-material/ArrowDropDown"; +import InfoIcon from "@mui/icons-material/Info"; +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import ButtonGroup from "@mui/material/ButtonGroup"; +import ClickAwayListener from "@mui/material/ClickAwayListener"; +import Grow from "@mui/material/Grow"; +import MenuItem from "@mui/material/MenuItem"; +import MenuList from "@mui/material/MenuList"; +import Paper from "@mui/material/Paper"; +import Popper from "@mui/material/Popper"; +import Tooltip from "@mui/material/Tooltip"; +import Typography from "@mui/material/Typography"; + +import { FullModalHeader, FullModalContent, FullModalFooter } from "@src/ui/components/FullModal"; +import { VerifiableCredentialItem } from "@src/ui/components/VerifiableCredential/Item"; + +import { usePresentVerifiableCredential } from "./usePresentVerifiableCredential"; + +const PresentVerifiableCredential = (): JSX.Element => { + const { + isWalletConnected, + isWalletInstalled, + verifiablePresentationRequest, + cryptkeeperVerifiableCredentials, + selectedVerifiableCredentialHashes, + error, + isMenuOpen, + menuSelectedIndex, + menuRef, + onCloseModal, + onRejectRequest, + onToggleSelection, + onToggleMenu, + onMenuItemClick, + onSubmitVerifiablePresentation, + } = usePresentVerifiableCredential(); + + const menuOptions = [ + isWalletConnected ? "Sign with Metamask" : "Connect to Metamask", + "Sign with Cryptkeeper", + "Proceed without Signing", + ]; + + return ( + + Request for Verifiable Credentials + + + + You have received a request to present Verifiable Credentials: + + + {verifiablePresentationRequest} + + {cryptkeeperVerifiableCredentials.map(({ verifiableCredential, metadata }) => ( + + ))} + + + {error && ( + + {error} + + )} + + + Choose Verifiable Credential signer + + + + + + + + + + + + + + + + + + {({ TransitionProps }) => ( + + + + + {menuOptions.map((option, index) => ( + { + onMenuItemClick(index); + }} + > + {option} + + ))} + + + + + )} + + + + + ); +}; + +export default PresentVerifiableCredential; diff --git a/packages/app/src/ui/pages/PresentVerifiableCredential/__tests__/PresentVerifiableCredential.test.tsx b/packages/app/src/ui/pages/PresentVerifiableCredential/__tests__/PresentVerifiableCredential.test.tsx new file mode 100644 index 000000000..f3016c3f4 --- /dev/null +++ b/packages/app/src/ui/pages/PresentVerifiableCredential/__tests__/PresentVerifiableCredential.test.tsx @@ -0,0 +1,190 @@ +/** + * @jest-environment jsdom + */ + +import { fireEvent, render, waitFor } from "@testing-library/react"; + +import PresentVerifiableCredential from ".."; +import { IUsePresentVerifiableCredentialData, usePresentVerifiableCredential } from "../usePresentVerifiableCredential"; + +jest.mock("../usePresentVerifiableCredential", (): unknown => ({ + ...jest.requireActual("../usePresentVerifiableCredential"), + usePresentVerifiableCredential: jest.fn(), +})); + +describe("ui/pages/PresentVerifiableCredential", () => { + const mockCryptkeeperVerifiableCredentials = [ + { + verifiableCredential: { + context: ["https://www.w3.org/2018/credentials/v1"], + id: "http://example.edu/credentials/3732", + type: ["VerifiableCredential"], + issuer: "did:example:123", + issuanceDate: new Date("2020-03-10T04:24:12.164Z"), + credentialSubject: { + id: "did:example:456", + claims: { + type: "BachelorDegree", + name: "Bachelor of Science and Arts", + }, + }, + }, + metadata: { + hash: "0x123", + name: "Credential #0", + }, + }, + { + verifiableCredential: { + context: ["https://www.w3.org/2018/credentials/v1"], + id: "http://example.edu/credentials/3733", + type: ["VerifiableCredential"], + issuer: "did:example:12345", + issuanceDate: new Date("2020-03-10T04:24:12.164Z"), + credentialSubject: { + id: "did:example:123", + claims: { + type: "BachelorDegree", + name: "Bachelor of Science and Arts", + }, + }, + }, + metadata: { + hash: "0x1234", + name: "Credential #1", + }, + }, + ]; + + const defaultHookData: IUsePresentVerifiableCredentialData = { + isWalletConnected: true, + isWalletInstalled: true, + verifiablePresentationRequest: "example presentation request", + cryptkeeperVerifiableCredentials: mockCryptkeeperVerifiableCredentials, + selectedVerifiableCredentialHashes: ["0x123"], + error: undefined, + isMenuOpen: false, + menuSelectedIndex: 0, + menuRef: { current: document.createElement("div") }, + onCloseModal: jest.fn(), + onRejectRequest: jest.fn(), + onToggleSelection: jest.fn(), + onToggleMenu: jest.fn(), + onMenuItemClick: jest.fn(), + onSubmitVerifiablePresentation: jest.fn(), + }; + + beforeEach(() => { + (usePresentVerifiableCredential as jest.Mock).mockReturnValue(defaultHookData); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + test("should render present verifiable credential page properly", async () => { + const { container, findByText } = render(); + + await waitFor(() => container.firstChild !== null); + + const credentialOne = await findByText(defaultHookData.cryptkeeperVerifiableCredentials[0].metadata.name); + const credentialTwo = await findByText(defaultHookData.cryptkeeperVerifiableCredentials[1].metadata.name); + + expect(credentialOne).toBeInTheDocument(); + expect(credentialTwo).toBeInTheDocument(); + }); + + test("should render an error properly", async () => { + const newError = "My Error"; + + (usePresentVerifiableCredential as jest.Mock).mockReturnValueOnce({ ...defaultHookData, error: newError }); + + const { container, findByText } = render(); + + await waitFor(() => container.firstChild !== null); + + const error = await findByText(newError); + + expect(error).toBeInTheDocument(); + }); + + test("should display connect to metamask", async () => { + (usePresentVerifiableCredential as jest.Mock).mockReturnValueOnce({ + ...defaultHookData, + isWalletConnected: false, + }); + + const { container, findAllByText } = render(); + + await waitFor(() => container.firstChild !== null); + + const metamask = await findAllByText("Connect to Metamask"); + + expect(metamask[0]).toBeInTheDocument(); + }); + + test("should reject a verifiable presentation request correctly", async () => { + const { container, findByTestId } = render(); + + await waitFor(() => container.firstChild !== null); + + const button = await findByTestId("reject-verifiable-presentation-request"); + fireEvent.click(button); + + expect(defaultHookData.onRejectRequest).toBeCalledTimes(1); + }); + + test("should toggle menu", async () => { + const { container, findAllByText, findByTestId } = render(); + + await waitFor(() => container.firstChild !== null); + + const metamask = await findAllByText("Sign with Metamask"); + + expect(metamask[0]).toBeInTheDocument(); + + const button = await findByTestId("sign-verifiable-presentation-selector"); + fireEvent.click(button); + + expect(defaultHookData.onToggleMenu).toBeCalledTimes(1); + }); + + test("should sign with metamask", async () => { + const { container, findAllByText, findByTestId } = render(); + + await waitFor(() => container.firstChild !== null); + + const metamask = await findAllByText("Sign with Metamask"); + + expect(metamask[0]).toBeInTheDocument(); + + const button = await findByTestId("sign-verifiable-presentation-button"); + fireEvent.click(button); + + expect(defaultHookData.onSubmitVerifiablePresentation).toBeCalledTimes(1); + }); + + test("should select menu items", async () => { + (usePresentVerifiableCredential as jest.Mock).mockReturnValueOnce({ + ...defaultHookData, + isMenuOpen: true, + menuRef: { current: document.createElement("div") }, + }); + + const { container, findByText, findByTestId } = render(); + + await waitFor(() => container.firstChild !== null); + + const cryptkeeper = await findByText("Sign with Cryptkeeper"); + const proceed = await findByText("Proceed without Signing"); + + expect(cryptkeeper).toBeInTheDocument(); + expect(proceed).toBeInTheDocument(); + + const button = await findByTestId("sign-verifiable-presentation-menu-2"); + fireEvent.click(button); + + expect(defaultHookData.onMenuItemClick).toBeCalledTimes(1); + expect(defaultHookData.onMenuItemClick).toBeCalledWith(2); + }); +}); diff --git a/packages/app/src/ui/pages/PresentVerifiableCredential/__tests__/usePresentVerifiableCredential.test.ts b/packages/app/src/ui/pages/PresentVerifiableCredential/__tests__/usePresentVerifiableCredential.test.ts new file mode 100644 index 000000000..b87ee7d0b --- /dev/null +++ b/packages/app/src/ui/pages/PresentVerifiableCredential/__tests__/usePresentVerifiableCredential.test.ts @@ -0,0 +1,548 @@ +/** + * @jest-environment jsdom + */ + +import { act, renderHook, waitFor } from "@testing-library/react"; + +import { defaultWalletHookData } from "@src/config/mock/wallet"; +import { closePopup } from "@src/ui/ducks/app"; +import { useAppDispatch } from "@src/ui/ducks/hooks"; +import { + generateVerifiablePresentation, + generateVerifiablePresentationWithCryptkeeper, + rejectVerifiablePresentationRequest, +} from "@src/ui/ducks/verifiableCredentials"; +import { useCryptkeeperVerifiableCredentials } from "@src/ui/hooks/verifiableCredentials"; +import { useCryptKeeperWallet, useEthWallet } from "@src/ui/hooks/wallet"; + +import type { BrowserProvider } from "ethers"; + +import { MenuItems, usePresentVerifiableCredential } from "../usePresentVerifiableCredential"; + +const mockCryptkeeperVerifiableCredentials = [ + { + verifiableCredential: { + context: ["https://www.w3.org/2018/credentials/v1"], + id: "http://example.edu/credentials/3732", + type: ["VerifiableCredential"], + issuer: "did:example:123", + issuanceDate: new Date("2020-03-10T04:24:12.164Z"), + credentialSubject: { + id: "did:example:456", + claims: { + type: "BachelorDegree", + name: "Bachelor of Science and Arts", + }, + }, + }, + metadata: { + hash: "0x123", + name: "Credential #0", + }, + }, + { + verifiableCredential: { + context: ["https://www.w3.org/2018/credentials/v1"], + id: "http://example.edu/credentials/3733", + type: ["VerifiableCredential"], + issuer: "did:example:12345", + issuanceDate: new Date("2020-03-10T04:24:12.164Z"), + credentialSubject: { + id: "did:example:123", + claims: { + type: "BachelorDegree", + name: "Bachelor of Science and Arts", + }, + }, + }, + metadata: { + hash: "0x1234", + name: "Credential #1", + }, + }, +]; + +jest.mock("@src/ui/hooks/verifiableCredentials", (): unknown => ({ + useCryptkeeperVerifiableCredentials: jest.fn(), +})); + +jest.mock("@src/ui/ducks/hooks", (): unknown => ({ + useAppDispatch: jest.fn(), +})); + +jest.mock("@src/ui/ducks/app", (): unknown => ({ + closePopup: jest.fn(), +})); + +jest.mock("@src/ui/ducks/verifiableCredentials", (): unknown => ({ + addVerifiableCredential: jest.fn(), + rejectVerifiableCredentialRequest: jest.fn(), + renameVerifiableCredential: jest.fn(), + deleteVerifiableCredential: jest.fn(), + fetchVerifiableCredentials: jest.fn(), + useVerifiableCredentials: jest.fn(), + generateVerifiablePresentation: jest.fn(), + generateVerifiablePresentationWithCryptkeeper: jest.fn(), + rejectVerifiablePresentationRequest: jest.fn(), +})); + +jest.mock("@src/ui/hooks/wallet", (): unknown => ({ + useEthWallet: jest.fn(), + useCryptKeeperWallet: jest.fn(), +})); + +describe("ui/pages/PresentVerifiableCredential/usePresentVerifiableCredential", () => { + const mockDispatch = jest.fn(); + + const exampleRequest = "exampleRequest"; + + const mockProvider = { + getSigner: () => ({ + signMessage: jest.fn(), + }), + } as unknown as BrowserProvider; + + const oldHref = window.location.href; + + Object.defineProperty(window, "location", { + value: { + href: oldHref, + }, + writable: true, + }); + + describe("basic hook functionality", () => { + beforeEach(() => { + (useAppDispatch as jest.Mock).mockReturnValue(mockDispatch); + + (useCryptkeeperVerifiableCredentials as jest.Mock).mockReturnValue(mockCryptkeeperVerifiableCredentials); + + (useEthWallet as jest.Mock).mockReturnValue({ ...defaultWalletHookData, provider: mockProvider, isActive: true }); + + (useCryptKeeperWallet as jest.Mock).mockReturnValue({ ...defaultWalletHookData, isActive: true }); + + window.location.href = `http://localhost:3000/generate-verifiable-presentation-request?request=${exampleRequest}`; + }); + + afterEach(() => { + jest.clearAllMocks(); + + window.location.href = oldHref; + }); + + test("should return initial data", async () => { + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + expect(result.current.selectedVerifiableCredentialHashes).toStrictEqual([]); + expect(result.current.error).toBe(undefined); + }); + }); + + test("should have no initial data if request is empty", async () => { + window.location.href = `http://localhost:3000/generate-verifiable-presentation-request`; + + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(undefined); + }); + }); + + test("should close the modal properly", async () => { + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + }); + + act(() => result.current.onCloseModal()); + + expect(closePopup).toBeCalledTimes(1); + expect(mockDispatch).toBeCalledTimes(2); + }); + + test("should reject a verifiable presentation request", async () => { + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + }); + + await act(async () => Promise.resolve(result.current.onRejectRequest())); + + expect(rejectVerifiablePresentationRequest).toBeCalledTimes(1); + expect(closePopup).toBeCalledTimes(1); + expect(mockDispatch).toBeCalledTimes(3); + }); + + test("should toggle selecting a verifiable credential", async () => { + const hash = "0x123"; + + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + }); + + act(() => result.current.onToggleSelection(hash)); + + expect(result.current.selectedVerifiableCredentialHashes).toStrictEqual([hash]); + + act(() => result.current.onToggleSelection(hash)); + + expect(result.current.selectedVerifiableCredentialHashes).toStrictEqual([]); + }); + + test("should erase error after toggling select", async () => { + const hash = "0x123"; + + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + }); + + act(() => result.current.onMenuItemClick(MenuItems.WITHOUT_SIGNATURE)); + await act(() => result.current.onSubmitVerifiablePresentation()); + + act(() => result.current.onToggleSelection(hash)); + + expect(result.current.error).toBe(undefined); + }); + + test("should toggle the menu properly", async () => { + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + }); + + expect(result.current.isMenuOpen).toBe(false); + + act(() => result.current.onToggleMenu()); + + expect(result.current.isMenuOpen).toBe(true); + }); + + test("should select a menu item properly", async () => { + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + }); + + expect(result.current.menuSelectedIndex).toBe(0); + + act(() => result.current.onMenuItemClick(MenuItems.CRYPTKEEPER)); + + expect(result.current.menuSelectedIndex).toBe(1); + + act(() => result.current.onMenuItemClick(MenuItems.WITHOUT_SIGNATURE)); + + expect(result.current.menuSelectedIndex).toBe(2); + + act(() => result.current.onMenuItemClick(MenuItems.METAMASK)); + + expect(result.current.menuSelectedIndex).toBe(0); + }); + + test("should close the menu properly", async () => { + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + }); + + expect(result.current.isMenuOpen).toBe(false); + + act(() => result.current.onToggleMenu()); + + expect(result.current.isMenuOpen).toBe(true); + + act(() => result.current.onToggleMenu()); + + expect(result.current.isMenuOpen).toBe(false); + }); + + test("should submit verifiable presentation without signature properly", async () => { + const hash = "0x123"; + + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + }); + + act(() => result.current.onToggleSelection(hash)); + act(() => result.current.onMenuItemClick(MenuItems.WITHOUT_SIGNATURE)); + await act(() => result.current.onSubmitVerifiablePresentation()); + + expect(generateVerifiablePresentation).toBeCalledTimes(1); + expect(closePopup).toBeCalledTimes(1); + expect(mockDispatch).toBeCalledTimes(3); + }); + + test("should fail to submit an empty verifiable presentation", async () => { + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + }); + + act(() => result.current.onMenuItemClick(MenuItems.WITHOUT_SIGNATURE)); + await act(() => result.current.onSubmitVerifiablePresentation()); + + expect(generateVerifiablePresentation).toBeCalledTimes(0); + expect(result.current.error).toBe("Please select at least one credential."); + }); + + test("should submit verifiable presentation with cryptkeeper signature properly", async () => { + const hash = "0x123"; + + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + }); + + act(() => result.current.onToggleSelection(hash)); + act(() => result.current.onMenuItemClick(MenuItems.CRYPTKEEPER)); + await act(() => result.current.onSubmitVerifiablePresentation()); + + expect(generateVerifiablePresentationWithCryptkeeper).toBeCalledTimes(1); + expect(closePopup).toBeCalledTimes(1); + expect(mockDispatch).toBeCalledTimes(3); + }); + + test("should fail to submit an empty verifiable presentation with cryptkeeper", async () => { + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + }); + + act(() => result.current.onMenuItemClick(MenuItems.CRYPTKEEPER)); + await act(() => result.current.onSubmitVerifiablePresentation()); + + expect(generateVerifiablePresentationWithCryptkeeper).toBeCalledTimes(0); + expect(result.current.error).toBe("Please select at least one credential."); + }); + + test("should submit verifiable presentation with Metamask signature properly", async () => { + const hash = "0x123"; + + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + }); + + act(() => result.current.onToggleSelection(hash)); + await act(() => result.current.onSubmitVerifiablePresentation()); + + expect(generateVerifiablePresentation).toBeCalledTimes(1); + expect(closePopup).toBeCalledTimes(1); + expect(mockDispatch).toBeCalledTimes(3); + }); + + test("should fail to submit an empty verifiable presentation with metamask", async () => { + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + }); + + await act(() => result.current.onSubmitVerifiablePresentation()); + + expect(generateVerifiablePresentationWithCryptkeeper).toBeCalledTimes(0); + expect(result.current.error).toBe("Please select at least one credential."); + }); + + test("should create error upon invalid menu index", async () => { + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + }); + + act(() => result.current.onMenuItemClick(3)); + await act(() => result.current.onSubmitVerifiablePresentation()); + + expect(generateVerifiablePresentation).toBeCalledTimes(0); + expect(generateVerifiablePresentationWithCryptkeeper).toBeCalledTimes(0); + expect(result.current.error).toBe("Invalid menu index."); + }); + }); + + describe("wallet connection error", () => { + beforeEach(() => { + (useAppDispatch as jest.Mock).mockReturnValue(mockDispatch); + + (useCryptkeeperVerifiableCredentials as jest.Mock).mockReturnValue(mockCryptkeeperVerifiableCredentials); + + (useEthWallet as jest.Mock).mockReturnValue({ + ...defaultWalletHookData, + onConnect: () => { + throw Error("error"); + }, + provider: { + getSigner: () => undefined, + }, + isActive: false, + }); + + (useCryptKeeperWallet as jest.Mock).mockReturnValue({ ...defaultWalletHookData, isActive: true }); + + window.location.href = `http://localhost:3000/generate-verifiable-presentation-request?request=${exampleRequest}`; + }); + + afterEach(() => { + jest.clearAllMocks(); + + window.location.href = oldHref; + }); + + test("should fail to connect wallet if connection is invalid", async () => { + const hash = "0x123"; + + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + }); + + act(() => result.current.onToggleSelection(hash)); + await act(() => result.current.onSubmitVerifiablePresentation()); + + expect(result.current.error).toStrictEqual("Wallet connection error"); + }); + }); + + describe("wallet provider error", () => { + beforeEach(() => { + (useAppDispatch as jest.Mock).mockReturnValue(mockDispatch); + + (useCryptkeeperVerifiableCredentials as jest.Mock).mockReturnValue(mockCryptkeeperVerifiableCredentials); + + (useEthWallet as jest.Mock).mockReturnValue({ + ...defaultWalletHookData, + provider: { + getSigner: () => undefined, + }, + isActive: true, + }); + + (useCryptKeeperWallet as jest.Mock).mockReturnValue({ ...defaultWalletHookData, isActive: true }); + + window.location.href = `http://localhost:3000/generate-verifiable-presentation-request?request=${exampleRequest}`; + }); + + afterEach(() => { + jest.clearAllMocks(); + + window.location.href = oldHref; + }); + + test("should fail to submit verifiable presentation if wallet is invalid", async () => { + const hash = "0x123"; + + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + }); + + act(() => result.current.onToggleSelection(hash)); + await act(() => result.current.onSubmitVerifiablePresentation()); + + expect(result.current.error).toStrictEqual("Could not connect to Ethereum account."); + }); + }); + + describe("wallet signer error", () => { + beforeEach(() => { + (useAppDispatch as jest.Mock).mockReturnValue(mockDispatch); + + (useCryptkeeperVerifiableCredentials as jest.Mock).mockReturnValue(mockCryptkeeperVerifiableCredentials); + + (useEthWallet as jest.Mock).mockReturnValue({ + ...defaultWalletHookData, + provider: { + getSigner: () => ({ + signMessage: () => { + throw new Error("error"); + }, + }), + }, + isActive: true, + }); + + (useCryptKeeperWallet as jest.Mock).mockReturnValue({ ...defaultWalletHookData, isActive: true }); + + window.location.href = `http://localhost:3000/generate-verifiable-presentation-request?request=${exampleRequest}`; + }); + + afterEach(() => { + jest.clearAllMocks(); + + window.location.href = oldHref; + }); + + test("should fail to submit verifiable presentation if signing errors", async () => { + const hash = "0x123"; + + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + }); + + act(() => result.current.onToggleSelection(hash)); + await act(() => result.current.onSubmitVerifiablePresentation()); + + expect(result.current.error).toStrictEqual("Failed to sign Verifiable Presentation."); + }); + }); + + describe("cryptkeeper address error", () => { + beforeEach(() => { + (useAppDispatch as jest.Mock).mockReturnValue(mockDispatch); + + (useCryptkeeperVerifiableCredentials as jest.Mock).mockReturnValue(mockCryptkeeperVerifiableCredentials); + + (useEthWallet as jest.Mock).mockReturnValue({ ...defaultWalletHookData, provider: mockProvider, isActive: true }); + + (useCryptKeeperWallet as jest.Mock).mockReturnValue({ + ...defaultWalletHookData, + isActive: true, + address: undefined, + }); + + window.location.href = `http://localhost:3000/generate-verifiable-presentation-request?request=${exampleRequest}`; + }); + + afterEach(() => { + jest.clearAllMocks(); + + window.location.href = oldHref; + }); + + test("should fail to submit verifiable presentation if cryptkeeper address is invalid", async () => { + const hash = "0x123"; + + const { result } = renderHook(() => usePresentVerifiableCredential()); + + await waitFor(() => { + expect(result.current.verifiablePresentationRequest).toStrictEqual(exampleRequest); + }); + + act(() => result.current.onToggleSelection(hash)); + act(() => result.current.onMenuItemClick(MenuItems.CRYPTKEEPER)); + await act(() => result.current.onSubmitVerifiablePresentation()); + + expect(result.current.error).toStrictEqual("Could not connect to Cryptkeeper account."); + }); + }); +}); diff --git a/packages/app/src/ui/pages/PresentVerifiableCredential/index.ts b/packages/app/src/ui/pages/PresentVerifiableCredential/index.ts new file mode 100644 index 000000000..db903c3c0 --- /dev/null +++ b/packages/app/src/ui/pages/PresentVerifiableCredential/index.ts @@ -0,0 +1,3 @@ +import { lazy } from "react"; + +export default lazy(() => import("./PresentVerifiableCredential")); diff --git a/packages/app/src/ui/pages/PresentVerifiableCredential/usePresentVerifiableCredential.ts b/packages/app/src/ui/pages/PresentVerifiableCredential/usePresentVerifiableCredential.ts new file mode 100644 index 000000000..98916d983 --- /dev/null +++ b/packages/app/src/ui/pages/PresentVerifiableCredential/usePresentVerifiableCredential.ts @@ -0,0 +1,259 @@ +import * as React from "react"; +import { useCallback, useEffect, useState } from "react"; + +import { + generateVerifiablePresentationFromVerifiableCredentials, + serializeVerifiablePresentation, +} from "@src/background/services/credentials/utils"; +import { closePopup } from "@src/ui/ducks/app"; +import { useAppDispatch } from "@src/ui/ducks/hooks"; +import { + fetchVerifiableCredentials, + rejectVerifiablePresentationRequest, + generateVerifiablePresentation, + generateVerifiablePresentationWithCryptkeeper, +} from "@src/ui/ducks/verifiableCredentials"; +import { useCryptkeeperVerifiableCredentials } from "@src/ui/hooks/verifiableCredentials"; +import { useCryptKeeperWallet, useEthWallet } from "@src/ui/hooks/wallet"; + +import type { IVerifiablePresentation } from "@cryptkeeperzk/types"; +import type { ICryptkeeperVerifiableCredential } from "@src/types"; + +const ETHEREUM_SIGNATURE_SPECIFICATION_TYPE = "EthereumEip712Signature2021"; +const VERIFIABLE_CREDENTIAL_PROOF_PURPOSE = "assertionMethod"; + +export enum MenuItems { + METAMASK = 0, + CRYPTKEEPER = 1, + WITHOUT_SIGNATURE = 2, +} + +export interface IUsePresentVerifiableCredentialData { + isWalletConnected: boolean; + isWalletInstalled: boolean; + verifiablePresentationRequest?: string; + cryptkeeperVerifiableCredentials: ICryptkeeperVerifiableCredential[]; + selectedVerifiableCredentialHashes: string[]; + error?: string; + isMenuOpen: boolean; + menuSelectedIndex: number; + menuRef: React.RefObject; + onCloseModal: () => void; + onRejectRequest: () => void; + onToggleSelection: (hash: string) => void; + onToggleMenu: () => void; + onMenuItemClick: (index: number) => void; + onSubmitVerifiablePresentation: () => Promise; +} + +export const usePresentVerifiableCredential = (): IUsePresentVerifiableCredentialData => { + const [verifiablePresentationRequest, setVerifiablePresentationRequest] = useState(); + const cryptkeeperVerifiableCredentials = useCryptkeeperVerifiableCredentials(); + const [selectedVerifiableCredentialHashes, setSelectedVerifiableCredentialHashes] = useState([]); + const [isMenuOpen, setIsMenuOpen] = React.useState(false); + const menuRef = React.useRef(null); + const [menuSelectedIndex, setMenuSelectedIndex] = React.useState(0); + const [error, setError] = useState(); + + const ethWallet = useEthWallet(); + const cryptKeeperWallet = useCryptKeeperWallet(); + const dispatch = useAppDispatch(); + const isWalletConnected = ethWallet.isActive; + + useEffect(() => { + const { searchParams } = new URL(window.location.href.replace("#", "")); + const request = searchParams.get("request"); + + if (!request) { + return; + } + + dispatch(fetchVerifiableCredentials()); + + setVerifiablePresentationRequest(request); + }, [setVerifiablePresentationRequest, fetchVerifiableCredentials, dispatch]); + + const onCloseModal = useCallback(() => { + dispatch(closePopup()); + }, [dispatch]); + + const onRejectVerifiablePresentationRequest = useCallback(async () => { + await dispatch(rejectVerifiablePresentationRequest()); + onCloseModal(); + }, [rejectVerifiablePresentationRequest, dispatch, onCloseModal]); + + const onToggleSelectVerifiableCredential = useCallback( + (selectedHash: string) => { + if (error) { + setError(undefined); + } + + if (selectedVerifiableCredentialHashes.includes(selectedHash)) { + setSelectedVerifiableCredentialHashes((hashes) => hashes.filter((hash) => hash !== selectedHash)); + } else { + setSelectedVerifiableCredentialHashes((hashes) => [...hashes, selectedHash]); + } + }, + [selectedVerifiableCredentialHashes, setSelectedVerifiableCredentialHashes, error, setError], + ); + + function createVerifiablePresentationFromSelectedCredentials(): IVerifiablePresentation | undefined { + if (selectedVerifiableCredentialHashes.length === 0) { + setError("Please select at least one credential."); + return undefined; + } + + const verifiableCredentials = cryptkeeperVerifiableCredentials + .filter((cryptkeeperVerifiableCredential) => + selectedVerifiableCredentialHashes.includes(cryptkeeperVerifiableCredential.metadata.hash), + ) + .map((cryptkeeperVerifiableCredential) => cryptkeeperVerifiableCredential.verifiableCredential); + + return generateVerifiablePresentationFromVerifiableCredentials(verifiableCredentials); + } + + const onToggleMenu = () => { + setIsMenuOpen((prevOpen) => !prevOpen); + }; + + const onMenuItemClick = (index: number) => { + setMenuSelectedIndex(index); + setIsMenuOpen(false); + }; + + const onConnectWallet = useCallback(async () => { + try { + await ethWallet.onConnect(); + } catch (e) { + setError("Wallet connection error"); + } + }, [setError, ethWallet.onConnect]); + + const onSubmitVerifiablePresentationWithMetamask = useCallback(async () => { + const verifiablePresentation = createVerifiablePresentationFromSelectedCredentials(); + + if (!verifiablePresentation) { + return; + } + + const address = ethWallet.address?.toLowerCase(); + const signer = await ethWallet.provider?.getSigner(); + + if (!address || !signer) { + setError("Could not connect to Ethereum account."); + return; + } + + try { + const serializedVerifiablePresentation = serializeVerifiablePresentation(verifiablePresentation); + const signature = await signer.signMessage(serializedVerifiablePresentation); + const signedVerifiablePresentation = { + ...verifiablePresentation, + proof: [ + { + type: [ETHEREUM_SIGNATURE_SPECIFICATION_TYPE], + proofPurpose: VERIFIABLE_CREDENTIAL_PROOF_PURPOSE, + verificationMethod: address, + created: new Date(), + proofValue: signature, + }, + ], + }; + + await dispatch(generateVerifiablePresentation(signedVerifiablePresentation)); + onCloseModal(); + } catch (e) { + setError("Failed to sign Verifiable Presentation."); + } + }, [ + ethWallet, + setError, + dispatch, + onCloseModal, + generateVerifiablePresentation, + createVerifiablePresentationFromSelectedCredentials, + ]); + + const onSubmitVerifiablePresentationWithCryptkeeper = useCallback(async () => { + const verifiablePresentation = createVerifiablePresentationFromSelectedCredentials(); + + if (!verifiablePresentation) { + return; + } + + const address = cryptKeeperWallet.address?.toLowerCase(); + + if (!address) { + setError("Could not connect to Cryptkeeper account."); + return; + } + + await dispatch(generateVerifiablePresentationWithCryptkeeper({ verifiablePresentation, address })); + onCloseModal(); + }, [ + cryptKeeperWallet, + setError, + dispatch, + onCloseModal, + generateVerifiablePresentationWithCryptkeeper, + createVerifiablePresentationFromSelectedCredentials, + ]); + + const onSubmitVerifiablePresentationWithoutSignature = useCallback(async () => { + const verifiablePresentation = createVerifiablePresentationFromSelectedCredentials(); + + if (!verifiablePresentation) { + return; + } + + await dispatch(generateVerifiablePresentation(verifiablePresentation)); + onCloseModal(); + }, [ + setError, + dispatch, + onCloseModal, + generateVerifiablePresentation, + createVerifiablePresentationFromSelectedCredentials, + ]); + + const onSubmitVerifiablePresentation = useCallback(async () => { + switch (true) { + case menuSelectedIndex === (MenuItems.METAMASK as number) && !isWalletConnected: + return onConnectWallet(); + case menuSelectedIndex === (MenuItems.METAMASK as number) && isWalletConnected: + return onSubmitVerifiablePresentationWithMetamask(); + case menuSelectedIndex === (MenuItems.CRYPTKEEPER as number): + return onSubmitVerifiablePresentationWithCryptkeeper(); + case menuSelectedIndex === (MenuItems.WITHOUT_SIGNATURE as number): + return onSubmitVerifiablePresentationWithoutSignature(); + default: + setError("Invalid menu index."); + return undefined; + } + }, [ + menuSelectedIndex, + isWalletConnected, + onConnectWallet, + onSubmitVerifiablePresentationWithMetamask, + onSubmitVerifiablePresentationWithCryptkeeper, + onSubmitVerifiablePresentationWithoutSignature, + ]); + + return { + isWalletInstalled: ethWallet.isInjectedWallet, + isWalletConnected, + verifiablePresentationRequest, + cryptkeeperVerifiableCredentials, + selectedVerifiableCredentialHashes, + error, + isMenuOpen, + menuSelectedIndex, + menuRef, + onCloseModal, + onRejectRequest: onRejectVerifiablePresentationRequest, + onToggleSelection: onToggleSelectVerifiableCredential, + onToggleMenu, + onMenuItemClick, + onSubmitVerifiablePresentation, + }; +}; diff --git a/packages/demo/index.tsx b/packages/demo/index.tsx index 4a9ebe030..daab7a7ac 100644 --- a/packages/demo/index.tsx +++ b/packages/demo/index.tsx @@ -52,8 +52,9 @@ const App = () => { connectIdentity, genSemaphoreProof, genRLNProof, - addVerifiableCredentialRequest, onRevealConnectedIdentityCommitment, + addVerifiableCredentialRequest, + generateVerifiablePresentationRequest, } = useCryptKeeper(); const params = new URLSearchParams(window.location.search); @@ -219,8 +220,32 @@ const App = () => {

Verifiable Credentials

- + +
+ +
+ + + +
+ +
+ +
)} diff --git a/packages/demo/useCryptKeeper.ts b/packages/demo/useCryptKeeper.ts index c417b8f9b..b49a4d26c 100644 --- a/packages/demo/useCryptKeeper.ts +++ b/packages/demo/useCryptKeeper.ts @@ -13,6 +13,8 @@ import type { IRLNSNARKProof, ConnectedIdentityMetadata, IVerifiableCredential, + IVerifiablePresentation, + IVerifiablePresentationRequest, } from "@cryptkeeperzk/types"; const SERVER_URL = process.env.MERKLE_MOCK_SERVER; @@ -30,21 +32,51 @@ const genMockIdentityCommitments = (): string[] => { return identityCommitments; }; -const genMockVerifiableCredential = (): IVerifiableCredential => ({ - context: ["https://www.w3.org/2018/credentials/v1"], - id: "http://example.edu/credentials/1872", - type: ["VerifiableCredential", "UniversityDegreeCredential"], - issuer: { - id: "did:example:76e12ec712ebc6f1c221ebfeb1f", - }, - issuanceDate: new Date("2010-01-01T19:23:24Z"), - credentialSubject: { - id: "did:example:ebfeb1f712ebc6f1c276e12ec21", - claims: { - type: "BachelorDegree", - name: "Bachelor of Science and Arts", +const genMockVerifiableCredential = (credentialType: string): IVerifiableCredential => { + const mockVerifiableCredentialMap: Record = { + UniversityDegreeCredential: { + context: ["https://www.w3.org/2018/credentials/v1"], + id: "http://example.edu/credentials/1872", + type: ["VerifiableCredential", "UniversityDegreeCredential"], + issuer: { + id: "did:example:76e12ec712ebc6f1c221ebfeb1f", + }, + issuanceDate: new Date("2010-01-01T19:23:24Z"), + credentialSubject: { + id: "did:example:ebfeb1f712ebc6f1c276e12ec21", + claims: { + type: "BachelorDegree", + name: "Bachelor of Science and Arts", + }, + }, }, - }, + DriversLicenseCredential: { + context: ["https://www.w3.org/2018/credentials/v1"], + id: "http://example.edu/credentials/1873", + type: ["VerifiableCredential", "DriversLicenseCredential"], + issuer: { + id: "did:example:76e12ec712ebc6f1c221ebfeb1e", + }, + issuanceDate: new Date("2020-01-01T19:23:24Z"), + credentialSubject: { + id: "did:example:ebfeb1f712ebc6f1c276e12ec21", + claims: { + name: "John Smith", + licenseNumber: "123-abc", + }, + }, + }, + }; + + if (!(credentialType in mockVerifiableCredentialMap)) { + throw new Error("Invalid credential type"); + } + + return mockVerifiableCredentialMap[credentialType]; +}; + +const genMockVerifiablePresentationRequest = (): IVerifiablePresentationRequest => ({ + request: "Please provide your University Degree Credential AND Drivers License Credential.", }); export enum MerkleProofType { @@ -64,8 +96,9 @@ interface IUseCryptKeeperData { getConnectedIdentity: () => void; genSemaphoreProof: (proofType: MerkleProofType) => void; genRLNProof: (proofType: MerkleProofType) => void; - addVerifiableCredentialRequest: () => Promise; onRevealConnectedIdentityCommitment: () => Promise; + addVerifiableCredentialRequest: (credentialType: string) => Promise; + generateVerifiablePresentationRequest: () => Promise; } export const useCryptKeeper = (): IUseCryptKeeperData => { @@ -160,11 +193,19 @@ export const useCryptKeeper = (): IUseCryptKeeperData => { }); }; - const addVerifiableCredentialRequest = useCallback(async () => { - const mockVerifiableCredential = genMockVerifiableCredential(); - const verifiableCredentialJson = JSON.stringify(mockVerifiableCredential); + const addVerifiableCredentialRequest = useCallback( + async (credentialType: string) => { + const mockVerifiableCredential = genMockVerifiableCredential(credentialType); + const verifiableCredentialJson = JSON.stringify(mockVerifiableCredential); + + await client?.DEV_addVerifiableCredentialRequest(verifiableCredentialJson); + }, + [client], + ); - await client?.addVerifiableCredentialRequest(verifiableCredentialJson); + const generateVerifiablePresentationRequest = useCallback(async () => { + const verifiablePresentationRequest = genMockVerifiablePresentationRequest(); + await client?.DEV_generateVerifiablePresentationRequest(verifiablePresentationRequest); }, [client]); const getConnectedIdentity = useCallback(async () => { @@ -227,6 +268,12 @@ export const useCryptKeeper = (): IUseCryptKeeperData => { [setConnectedIdentityCommitment], ); + const onGenerateVerifiablePresentation = useCallback((verifiablePresentation: unknown) => { + const credentialList = (verifiablePresentation as IVerifiablePresentation).verifiableCredential; + const credentialCount = credentialList ? credentialList.length : 0; + toast(`Generated a Verifiable Presentation from ${credentialCount} credentials!`, { type: "success" }); + }, []); + useEffect(() => { if (!client) { return undefined; @@ -236,6 +283,7 @@ export const useCryptKeeper = (): IUseCryptKeeperData => { client.on(EventName.IDENTITY_CHANGED, onIdentityChanged); client.on(EventName.LOGOUT, onLogout); client.on(EventName.ADD_VERIFIABLE_CREDENTIAL, onAddVerifiableCredential); + client.on(EventName.GENERATE_VERIFIABLE_PRESENTATION, onGenerateVerifiablePresentation); client.on(EventName.USER_REJECT, onReject); client.on(EventName.REVEAL_COMMITMENT, onRevealCommitment); @@ -259,6 +307,7 @@ export const useCryptKeeper = (): IUseCryptKeeperData => { genSemaphoreProof, genRLNProof, addVerifiableCredentialRequest, + generateVerifiablePresentationRequest, onRevealConnectedIdentityCommitment, }; }; diff --git a/packages/providers/src/constants/rpcAction.ts b/packages/providers/src/constants/rpcAction.ts index a784e2777..6db0ba701 100644 --- a/packages/providers/src/constants/rpcAction.ts +++ b/packages/providers/src/constants/rpcAction.ts @@ -65,6 +65,10 @@ export enum RPCAction { GET_ALL_VERIFIABLE_CREDENTIALS = "rpc/credentials/getAllVerifiableCredentials", DELETE_VERIFIABLE_CREDENTIAL = "rpc/credentials/deleteVerifiableCredential", DELETE_ALL_VERIFIABLE_CREDENTIALS = "rpc/credentials/deleteAllVerifiableCredentials", + GENERATE_VERIFIABLE_PRESENTATION = "rpc/credentials/generateVerifiablePresentation", + GENERATE_VERIFIABLE_PRESENTATION_WITH_CRYPTKEEPER = "rpc/credentials/generateVerifiablePresentationWithCryptkeeper", + GENERATE_VERIFIABLE_PRESENTATION_REQUEST = "rpc/credentials/generateVerifiablePresentationRequest", + REJECT_VERIFIABLE_PRESENTATION_REQUEST = "rpc/credentials/rejectVerifiablePresentationRequest", GENERATE_RLN_PROOF = "rpc/proofs/generate-rln-proof", GENERATE_RLN_PROOF_OFFSCREEN = "rpc/proofs/generate-rln-proof-offscreen", RLN_PROOF_RESULT = "rpc/proofs/rln-proof-result", diff --git a/packages/providers/src/event/types.ts b/packages/providers/src/event/types.ts index ab991e9e8..aea269537 100644 --- a/packages/providers/src/event/types.ts +++ b/packages/providers/src/event/types.ts @@ -16,6 +16,8 @@ export type EventHandler = (data: unknown) => void; * @property {string} IDENTITY_CHANGED - "identityChanged" * @property {string} LOGOUT - "logout" * @property {string} ADD_VERIFIABLE_CREDENTIAL - "addVerifiableCredential" + * @property {string} VERIFIABLE_PRESENTATION_REQUEST - "verifiablePresentationRequest" + * @property {string} GENERATE_VERIFIABLE_PRESENTATION - "generateVerifiablePresentation" * @property {string} REVEAL_COMMITMENT - "revealCommitment" * @property {string} JOIN_GROUP - "joinGroup" * @property {string} GROUP_MERKLE_PROOF - "groupMerkleProof" @@ -26,6 +28,8 @@ export enum EventName { IDENTITY_CHANGED = "identityChanged", LOGOUT = "logout", ADD_VERIFIABLE_CREDENTIAL = "addVerifiableCredential", + VERIFIABLE_PRESENTATION_REQUEST = "verifiablePresentationRequest", + GENERATE_VERIFIABLE_PRESENTATION = "generateVerifiablePresentation", REVEAL_COMMITMENT = "revealCommitment", JOIN_GROUP = "joinGroup", GROUP_MERKLE_PROOF = "groupMerkleProof", diff --git a/packages/providers/src/sdk/CryptKeeperInjectedProvider.ts b/packages/providers/src/sdk/CryptKeeperInjectedProvider.ts index 26a4dc7ea..b36d756ca 100644 --- a/packages/providers/src/sdk/CryptKeeperInjectedProvider.ts +++ b/packages/providers/src/sdk/CryptKeeperInjectedProvider.ts @@ -12,6 +12,7 @@ import { IRLNSNARKProof, IJoinGroupMemberArgs, IGenerateGroupMerkleProofArgs, + IVerifiablePresentationRequest, } from "@cryptkeeperzk/types"; import { RPCAction } from "../constants"; @@ -27,6 +28,7 @@ const EVENTS = [ EventName.LOGIN, EventName.LOGOUT, EventName.ADD_VERIFIABLE_CREDENTIAL, + EventName.GENERATE_VERIFIABLE_PRESENTATION, EventName.REVEAL_COMMITMENT, EventName.JOIN_GROUP, EventName.GROUP_MERKLE_PROOF, @@ -368,26 +370,43 @@ export class CryptKeeperInjectedProvider { } /** - * Requests user to add a verifiable credential. + * Requests user to reveal a connected identity commitment. * - * @param {string} serializedVerifiableCredential - The json string representation of the verifiable credential to add. + * @returns {Promise} + */ + async revealConnectedIdentityRequest(): Promise { + await this.post({ + method: RPCAction.REVEAL_CONNECTED_IDENTITY_COMMITMENT_REQUEST, + }); + } + + /** + * Requests user to provide a verifiable presentation. + * NOTE: THIS FUNCTION IS UNDER DEVELOPMENT AND NOT READY FOR PRODUCTION USE + * + * @param {IVerifiablePresentationRequest} verifiablePresentationRequest - The information provided to the user when requesting a verifiable presentation. * @returns {void} */ - async addVerifiableCredentialRequest(serializedVerifiableCredential: string): Promise { + async DEV_generateVerifiablePresentationRequest( + verifiablePresentationRequest: IVerifiablePresentationRequest, + ): Promise { await this.post({ - method: RPCAction.ADD_VERIFIABLE_CREDENTIAL_REQUEST, - payload: serializedVerifiableCredential, + method: RPCAction.GENERATE_VERIFIABLE_PRESENTATION_REQUEST, + payload: verifiablePresentationRequest, }); } /** * Requests user to reveal a connected identity commitment. + * NOTE: THIS FUNCTION IS UNDER DEVELOPMENT AND NOT READY FOR PRODUCTION USE * - * @returns {Promise} + * @param {string} serializedVerifiableCredential - The json string representation of the verifiable credential to add. + * @returns {void} */ - async revealConnectedIdentityRequest(): Promise { + async DEV_addVerifiableCredentialRequest(serializedVerifiableCredential: string): Promise { await this.post({ - method: RPCAction.REVEAL_CONNECTED_IDENTITY_COMMITMENT_REQUEST, + method: RPCAction.ADD_VERIFIABLE_CREDENTIAL_REQUEST, + payload: serializedVerifiableCredential, }); } diff --git a/packages/types/src/index.ts b/packages/types/src/index.ts index 58bd5b2f3..373887112 100644 --- a/packages/types/src/index.ts +++ b/packages/types/src/index.ts @@ -49,12 +49,13 @@ export type { IRequestHandler, IPendingRequest, IRejectedRequest } from "./reque export { RequestResolutionStatus, PendingRequestType } from "./request"; export type { IVerifiableCredential, - IVerifiablePresentation, ICredentialIssuer, ICredentialSubject, ICredentialStatus, ICredentialProof, ClaimValue, + IVerifiablePresentation, + IVerifiablePresentationRequest, } from "./verifiableCredentials"; export type { IJoinGroupMemberArgs, diff --git a/packages/types/src/verifiableCredentials/index.ts b/packages/types/src/verifiableCredentials/index.ts index b21ed49f4..3f8e08214 100644 --- a/packages/types/src/verifiableCredentials/index.ts +++ b/packages/types/src/verifiableCredentials/index.ts @@ -1,217 +1,9 @@ -/** - * The following references are derived from the Verifiable Credentials Data Model 1.0 Standard - * (https://www.w3.org/TR/vc-data-model). - */ - -/** - * @typedef {object} IVerifiableCredential - * @description A credential is a set of one or more claims made by the same entity. - * Credentials might also include an identifier and metadata to describe properties - * of the credential, such as the issuer, the expiry date and time, a representative - * image, a public key to use for verification purposes, the revocation mechanism, - * and so on. The metadata might be signed by the issuer. A verifiable credential is - * a set of tamper-evident claims and metadata that cryptographically prove who issued it. - */ -export interface IVerifiableCredential { - /** - * @property {string[]} context - The value of the @context property MUST be an ordered set where the first - * item is a URI with the value https://www.w3.org/2018/credentials/v1. - */ - context: string[]; - - /** - * @property {string} [id] - If the id property is present, the id property MUST express an identifier - * that others are expected to use when expressing statements about a specific thing - * identified by that identifier. - */ - id?: string; - - /** - * @property {string[]} type - The value of the type property MUST be, or map to (through interpretation - * of the @context property), one or more URIs. - */ - type: string[]; - - /** - * @property {string|CredentialIssuer} issuer - The value of the issuer property MUST be either a URI or an object - * containing an id property. - */ - issuer: string | ICredentialIssuer; - - /** - * @property {Date} issuanceDate - A credential MUST have an issuanceDate property. The value of the - * issuanceDate property MUST be a string value of an XMLSCHEMA11-2 combined date-time - * string representing the date and time the credential becomes valid, which could be a - * date and time in the future. - */ - issuanceDate: Date; - - /** - * @property {Date} [expirationDate] - If present, the value of the expirationDate property MUST be a string - * value of an XMLSCHEMA11-2 date-time representing the date and time the credential - * ceases to be valid. - */ - expirationDate?: Date; - - /** - * @property {CredentialSubject} credentialSubject - The value of the credentialSubject property is defined as a set of objects - * that contain one or more properties that are each related to a subject of the verifiable - * credential. Each object MAY contain an id. - */ - credentialSubject: ICredentialSubject; - - /** - * @property {CredentialStatus} [credentialStatus] - If present, the value of the credentialStatus property MUST include the - * following: id property, which MUST be a URI, and type property, which expresses the - * credential status type. - */ - credentialStatus?: ICredentialStatus; - - /** - * @property {CredentialProof[]} [proof] - One or more cryptographic proofs that can be used to detect tampering and - * verify the authorship of a credential or presentation. The specific method used for an - * embedded proof MUST be included using the type property. - */ - proof?: ICredentialProof[]; -} - -/** - * @typedef {object} VerifiablePresentation - * @description Presentations MAY be used to combine and present credentials. They can be - * packaged in such a way that the authorship of the data is verifiable. The data in a - * presentation is often all about the same subject, but there is no limit to the number - * of subjects or issuers in the data. The aggregation of information from multiple - * verifiable credentials is a typical use of verifiable presentations. - */ -export interface IVerifiablePresentation { - /** - * @property {string[]} context - The context of the presentation. - */ - context: string[]; - - /** - * @property {string} [id] - The id property is optional and MAY be used to provide a unique identifier - * for the presentation. - */ - id?: string; - - /** - * @property {string[]} type - The type property is required and expresses the type of presentation, - * such as VerifiablePresentation. - */ - type: string[]; - - /** - * @property {VerifiableCredential[]} [verifiableCredential] - If present, the value of the verifiableCredential property MUST be - * constructed from one or more verifiable credentials, or of data derived from - * verifiable credentials in a cryptographically verifiable format. - */ - verifiableCredential?: IVerifiableCredential[]; - - /** - * @property {string} [holder] - If present, the value of the holder property is expected to be a - * URI for the entity that is generating the presentation. - */ - holder?: string; - - /** - * @property {CredentialProof[]} [proof] - The cryptographic proofs for the verifiable presentation. - */ - proof?: ICredentialProof[]; -} - -/** - * @typedef {object} CredentialIssuer - * @description Represents the issuer of a credential. - */ -export interface ICredentialIssuer { - /** - * @property {string} [id] - The identifier of the issuer. - */ - id?: string; -} - -/** - * @typedef {object} CredentialSubject - * @description Represents the subject of a credential. - */ -export interface ICredentialSubject { - /** - * @property {string} [id] - The identifier of the subject. - */ - id?: string; - - /** - * @property {Record} claims - The subject claims. - */ - claims: Record; -} - -/** - * @typedef {object} CredentialStatus - * @description Represents the status of a credential. - */ -export interface ICredentialStatus { - /** - * @property {string} id - The identifier of the credential status. - */ - id: string; - - /** - * @property {string} type - The type of the credential status. - */ - type: string; -} - -/** - * @typedef {string | ClaimValue[] | {[key: string]: ClaimValue}} ClaimValue - * - * @description Represents the value of a claim in a verifiable credential. - * The value of a claim can be a string, an array of claim values, or an object - * containing nested claim values. This allows for flexible representation of claim values - * in verifiable credentials. - */ -export type ClaimValue = string | ClaimValue[] | { [key: string]: ClaimValue }; - -/** - * @typedef {object} CredentialProof - * @description A data integrity proof provides information about the proof mechanism, - * parameters required to verify that proof, and the proof value itself. - */ -export interface ICredentialProof { - /** - * @property {string} [id] - An optional identifier for the proof, which MUST be a URL, such as a - * UUID as a URN (urn:uuid:6a1676b8-b51f-11ed-937b-d76685a20ff5). - */ - id?: string; - - /** - * @property {string[]} type - The specific proof type used for the cryptographic proof MUST be - * specified as a string that maps to a URL. - */ - type: string[]; - - /** - * @property {string} proofPurpose - The reason the proof was created MUST be specified as a string that - * maps to a URL. - */ - proofPurpose: string; - - /** - * @property {string} verificationMethod - The means and information needed to verify the proof MUST be specified - * as a string that maps to a URL. - */ - verificationMethod: string; - - /** - * @property {Date} created - The date and time the proof was created MUST be specified as an - * XMLSCHEMA11-2 combined date and time string. - */ - created: Date; - - /** - * @property {string} proofValue - A string value that contains the data necessary to verify the digital - * proof using the verificationMethod specified. - */ - proofValue: string; -} +export type { + IVerifiableCredential, + ICredentialIssuer, + ICredentialSubject, + ICredentialStatus, + ICredentialProof, + ClaimValue, +} from "./verifiableCredentials"; +export type { IVerifiablePresentation, IVerifiablePresentationRequest } from "./verifiablePresentations"; diff --git a/packages/types/src/verifiableCredentials/verifiableCredentials.ts b/packages/types/src/verifiableCredentials/verifiableCredentials.ts new file mode 100644 index 000000000..3bc624221 --- /dev/null +++ b/packages/types/src/verifiableCredentials/verifiableCredentials.ts @@ -0,0 +1,172 @@ +/** + * The following references are derived from the Verifiable Credentials Data Model 1.0 Standard + * (https://www.w3.org/TR/vc-data-model). + */ + +/** + * @typedef {object} IVerifiableCredential + * @description A credential is a set of one or more claims made by the same entity. + * Credentials might also include an identifier and metadata to describe properties + * of the credential, such as the issuer, the expiry date and time, a representative + * image, a public key to use for verification purposes, the revocation mechanism, + * and so on. The metadata might be signed by the issuer. A verifiable credential is + * a set of tamper-evident claims and metadata that cryptographically prove who issued it. + */ +export interface IVerifiableCredential { + /** + * @property {string[]} context - The value of the @context property MUST be an ordered set where the first + * item is a URI with the value https://www.w3.org/2018/credentials/v1. + */ + context: string[]; + + /** + * @property {string} [id] - If the id property is present, the id property MUST express an identifier + * that others are expected to use when expressing statements about a specific thing + * identified by that identifier. + */ + id?: string; + + /** + * @property {string[]} type - The value of the type property MUST be, or map to (through interpretation + * of the @context property), one or more URIs. + */ + type: string[]; + + /** + * @property {string|CredentialIssuer} issuer - The value of the issuer property MUST be either a URI or an object + * containing an id property. + */ + issuer: string | ICredentialIssuer; + + /** + * @property {Date} issuanceDate - A credential MUST have an issuanceDate property. The value of the + * issuanceDate property MUST be a string value of an XMLSCHEMA11-2 combined date-time + * string representing the date and time the credential becomes valid, which could be a + * date and time in the future. + */ + issuanceDate: Date; + + /** + * @property {Date} [expirationDate] - If present, the value of the expirationDate property MUST be a string + * value of an XMLSCHEMA11-2 date-time representing the date and time the credential + * ceases to be valid. + */ + expirationDate?: Date; + + /** + * @property {CredentialSubject} credentialSubject - The value of the credentialSubject property is defined as a set of objects + * that contain one or more properties that are each related to a subject of the verifiable + * credential. Each object MAY contain an id. + */ + credentialSubject: ICredentialSubject; + + /** + * @property {CredentialStatus} [credentialStatus] - If present, the value of the credentialStatus property MUST include the + * following: id property, which MUST be a URI, and type property, which expresses the + * credential status type. + */ + credentialStatus?: ICredentialStatus; + + /** + * @property {CredentialProof[]} [proof] - One or more cryptographic proofs that can be used to detect tampering and + * verify the authorship of a credential or presentation. The specific method used for an + * embedded proof MUST be included using the type property. + */ + proof?: ICredentialProof[]; +} + +/** + * @typedef {object} CredentialIssuer + * @description Represents the issuer of a credential. + */ +export interface ICredentialIssuer { + /** + * @property {string} [id] - The identifier of the issuer. + */ + id?: string; +} + +/** + * @typedef {object} CredentialSubject + * @description Represents the subject of a credential. + */ +export interface ICredentialSubject { + /** + * @property {string} [id] - The identifier of the subject. + */ + id?: string; + + /** + * @property {Record} claims - The subject claims. + */ + claims: Record; +} + +/** + * @typedef {object} CredentialStatus + * @description Represents the status of a credential. + */ +export interface ICredentialStatus { + /** + * @property {string} id - The identifier of the credential status. + */ + id: string; + + /** + * @property {string} type - The type of the credential status. + */ + type: string; +} + +/** + * @typedef {string | ClaimValue[] | {[key: string]: ClaimValue}} ClaimValue + * + * @description Represents the value of a claim in a verifiable credential. + * The value of a claim can be a string, an array of claim values, or an object + * containing nested claim values. This allows for flexible representation of claim values + * in verifiable credentials. + */ +export type ClaimValue = string | ClaimValue[] | { [key: string]: ClaimValue }; + +/** + * @typedef {object} CredentialProof + * @description A data integrity proof provides information about the proof mechanism, + * parameters required to verify that proof, and the proof value itself. + */ +export interface ICredentialProof { + /** + * @property {string} [id] - An optional identifier for the proof, which MUST be a URL, such as a + * UUID as a URN (urn:uuid:6a1676b8-b51f-11ed-937b-d76685a20ff5). + */ + id?: string; + + /** + * @property {string[]} type - The specific proof type used for the cryptographic proof MUST be + * specified as a string that maps to a URL. + */ + type: string[]; + + /** + * @property {string} proofPurpose - The reason the proof was created MUST be specified as a string that + * maps to a URL. + */ + proofPurpose: string; + + /** + * @property {string} verificationMethod - The means and information needed to verify the proof MUST be specified + * as a string that maps to a URL. + */ + verificationMethod: string; + + /** + * @property {Date} created - The date and time the proof was created MUST be specified as an + * XMLSCHEMA11-2 combined date and time string. + */ + created: Date; + + /** + * @property {string} proofValue - A string value that contains the data necessary to verify the digital + * proof using the verificationMethod specified. + */ + proofValue: string; +} diff --git a/packages/types/src/verifiableCredentials/verifiablePresentations.ts b/packages/types/src/verifiableCredentials/verifiablePresentations.ts new file mode 100644 index 000000000..50f1c644f --- /dev/null +++ b/packages/types/src/verifiableCredentials/verifiablePresentations.ts @@ -0,0 +1,63 @@ +/** + * The following references are derived from the Verifiable Credentials Data Model 1.0 Standard + * (https://www.w3.org/TR/vc-data-model). + */ + +import { ICredentialProof, IVerifiableCredential } from "./verifiableCredentials"; + +/** + * @typedef {object} VerifiablePresentation + * @description Presentations MAY be used to combine and present credentials. They can be + * packaged in such a way that the authorship of the data is verifiable. The data in a + * presentation is often all about the same subject, but there is no limit to the number + * of subjects or issuers in the data. The aggregation of information from multiple + * verifiable credentials is a typical use of verifiable presentations. + */ +export interface IVerifiablePresentation { + /** + * @property {string[]} context - The context of the presentation. + */ + context: string[]; + + /** + * @property {string} [id] - The id property is optional and MAY be used to provide a unique identifier + * for the presentation. + */ + id?: string; + + /** + * @property {string[]} type - The type property is required and expresses the type of presentation, + * such as VerifiablePresentation. + */ + type: string[]; + + /** + * @property {VerifiableCredential[]} [verifiableCredential] - If present, the value of the verifiableCredential property MUST be + * constructed from one or more verifiable credentials, or of data derived from + * verifiable credentials in a cryptographically verifiable format. + */ + verifiableCredential?: IVerifiableCredential[]; + + /** + * @property {string} [holder] - If present, the value of the holder property is expected to be a + * URI for the entity that is generating the presentation. + */ + holder?: string; + + /** + * @property {CredentialProof[]} [proof] - The cryptographic proofs for the verifiable presentation. + */ + proof?: ICredentialProof[]; +} + +/** + * @typedef {object} VerifiablePresentationRequest + * @description A verifiable presentation request is the information required to generate a + * request for verifiable credentials. + */ +export interface IVerifiablePresentationRequest { + /** + * @property {string} request - The string presented to the user when prompting them for verifiable credentials. + */ + request: string; +}