diff --git a/packages/browser-wallet/package.json b/packages/browser-wallet/package.json index e7c2c7ead..7a611142c 100644 --- a/packages/browser-wallet/package.json +++ b/packages/browser-wallet/package.json @@ -33,6 +33,7 @@ "i18next-browser-languagedetector": "^6.1.4", "jotai": "^1.6.5", "json-bigint": "^1.0.0", + "jsonschema": "^1.4.1", "leb128": "^0.0.5", "lodash.debounce": "^4.0.8", "lodash.groupby": "^4.6.0", diff --git a/packages/browser-wallet/src/assets/svg/block.svg b/packages/browser-wallet/src/assets/svg/block.svg new file mode 100644 index 000000000..38530dd63 --- /dev/null +++ b/packages/browser-wallet/src/assets/svg/block.svg @@ -0,0 +1,3 @@ + + + diff --git a/packages/browser-wallet/src/assets/svg/pending.svg b/packages/browser-wallet/src/assets/svg/pending.svg new file mode 100644 index 000000000..81bbf1e9d --- /dev/null +++ b/packages/browser-wallet/src/assets/svg/pending.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/browser-wallet/src/assets/svg/revoked.svg b/packages/browser-wallet/src/assets/svg/revoked.svg new file mode 100644 index 000000000..63fa1f8c5 --- /dev/null +++ b/packages/browser-wallet/src/assets/svg/revoked.svg @@ -0,0 +1,5 @@ + + + + diff --git a/packages/browser-wallet/src/assets/svg/verified.svg b/packages/browser-wallet/src/assets/svg/verified.svg new file mode 100644 index 000000000..22c3cc1f4 --- /dev/null +++ b/packages/browser-wallet/src/assets/svg/verified.svg @@ -0,0 +1,4 @@ + + + + diff --git a/packages/browser-wallet/src/popup/constants/dimensions.ts b/packages/browser-wallet/src/popup/constants/dimensions.ts index 8dada5431..dbf774cdb 100644 --- a/packages/browser-wallet/src/popup/constants/dimensions.ts +++ b/packages/browser-wallet/src/popup/constants/dimensions.ts @@ -17,6 +17,6 @@ export const medium: Dimensions = { // >=1440p export const large: Dimensions = { - width: 354, + width: 375, height: 600, // Max allowed }; diff --git a/packages/browser-wallet/src/popup/constants/routes.ts b/packages/browser-wallet/src/popup/constants/routes.ts index 0aa71a5e1..e1a9b2dcf 100644 --- a/packages/browser-wallet/src/popup/constants/routes.ts +++ b/packages/browser-wallet/src/popup/constants/routes.ts @@ -18,6 +18,9 @@ export const relativeRoutes = { path: 'identities', add: { path: 'add' }, }, + verifiableCredentials: { + path: 'verifiable-credentials', + }, settings: { path: 'settings', allowlist: { diff --git a/packages/browser-wallet/src/popup/page-layouts/ExternalRequestLayout/ExternalRequestLayout.tsx b/packages/browser-wallet/src/popup/page-layouts/ExternalRequestLayout/ExternalRequestLayout.tsx index bf72c7f1d..d51d9af43 100644 --- a/packages/browser-wallet/src/popup/page-layouts/ExternalRequestLayout/ExternalRequestLayout.tsx +++ b/packages/browser-wallet/src/popup/page-layouts/ExternalRequestLayout/ExternalRequestLayout.tsx @@ -18,7 +18,7 @@ function Header() { return t('header.connect'); } if (pathname.startsWith(absoluteRoutes.prompt.connectAccountsRequest.path)) { - return t('header.connectAccountsRequest'); + return t('header.allowlistingRequest'); } if (pathname.startsWith(absoluteRoutes.prompt.addTokens.path)) { return t('header.addTokens'); diff --git a/packages/browser-wallet/src/popup/page-layouts/MainLayout/Header/Header.tsx b/packages/browser-wallet/src/popup/page-layouts/MainLayout/Header/Header.tsx index f9198671d..0ab8f4087 100644 --- a/packages/browser-wallet/src/popup/page-layouts/MainLayout/Header/Header.tsx +++ b/packages/browser-wallet/src/popup/page-layouts/MainLayout/Header/Header.tsx @@ -54,6 +54,7 @@ const transitionVariants: Variants = { enum Section { Account, Id, + VerifiableCredentials, Settings, } @@ -67,6 +68,8 @@ function getTitle(section: Section, pathname: string) { return 'header.accounts'; case Section.Id: return 'header.ids'; + case Section.VerifiableCredentials: + return 'header.verifiableCredentials'; case Section.Settings: { if (pathname.startsWith(absoluteRoutes.home.settings.allowlist.path)) { return 'header.settings.allowlist'; @@ -94,6 +97,9 @@ function getSection(pathname: string): Section { if (pathname.startsWith(absoluteRoutes.home.identities.path)) { return Section.Id; } + if (pathname.startsWith(absoluteRoutes.home.verifiableCredentials.path)) { + return Section.VerifiableCredentials; + } if (pathname.startsWith(absoluteRoutes.home.settings.path)) { return Section.Settings; } @@ -190,6 +196,12 @@ export default function Header({ onToggle, className }: Props) { setNavOpen(false)} to={absoluteRoutes.home.identities.path}> {t('header.ids')} + setNavOpen(false)} + to={absoluteRoutes.home.verifiableCredentials.path} + > + {t('header.verifiableCredentials')} + setNavOpen(false)} to={absoluteRoutes.home.settings.path}> {t('header.settings.main')} diff --git a/packages/browser-wallet/src/popup/page-layouts/MainLayout/i18n/da.ts b/packages/browser-wallet/src/popup/page-layouts/MainLayout/i18n/da.ts index fa6da79ab..33d10c2bb 100644 --- a/packages/browser-wallet/src/popup/page-layouts/MainLayout/i18n/da.ts +++ b/packages/browser-wallet/src/popup/page-layouts/MainLayout/i18n/da.ts @@ -9,6 +9,7 @@ const t: typeof en = { header: { accounts: 'Konti', ids: 'ID kort', + verifiableCredentials: 'Legitimationsoplysninger', settings: { main: 'Wallet indstillinger', allowlist: 'Tilladelsesliste', diff --git a/packages/browser-wallet/src/popup/page-layouts/MainLayout/i18n/en.ts b/packages/browser-wallet/src/popup/page-layouts/MainLayout/i18n/en.ts index 7d42196c0..466707a95 100644 --- a/packages/browser-wallet/src/popup/page-layouts/MainLayout/i18n/en.ts +++ b/packages/browser-wallet/src/popup/page-layouts/MainLayout/i18n/en.ts @@ -7,6 +7,7 @@ const t = { header: { accounts: 'Accounts', ids: 'ID cards', + verifiableCredentials: 'Verifiable credentials', settings: { main: 'Wallet settings', allowlist: 'Allowlist', diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredential.scss b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredential.scss new file mode 100644 index 000000000..9782d695f --- /dev/null +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredential.scss @@ -0,0 +1,88 @@ +.verifiable-credential-list { + overflow-y: auto; +} + +.verifiable-credential { + color: $color-white; + border-radius: rem(16px); + margin: rem(16px); + box-shadow: rgb(99 99 99 / 20%) rem(0) rem(2px) rem(8px) rem(0); + position: relative; + + &__header { + display: flex; + align-items: center; + height: 62px; + + &__logo { + flex-shrink: 0; + margin-left: rem(11px); + width: rem(28px); + height: rem(28px); + + svg { + path { + fill: $color-white; + } + } + } + + &__title { + font-size: rem(10px); + margin-left: rem(7px); + } + + &__status { + display: flex; + font-size: rem(8px); + color: $color-white; + margin-right: rem(11px); + margin-left: auto; + align-items: center; + + svg { + margin-left: rem(5px); + } + + path { + fill: $color-white; + } + } + } + + &__image { + margin-left: rem(8px); + margin-right: rem(8px); + height: 120px; + width: 308px; + + img { + max-width: 100%; + max-height: 100%; + } + } + + &__body-attributes { + padding: rem(11px); + + &-row { + &:not(:last-child) { + margin-bottom: rem(10px); + } + + label { + color: $color-white; + opacity: 0.5; + font-size: rem(9px); + font-variant: small-caps; + display: block; + } + + &-value { + display: block; + font-size: rem(10px); + font-weight: $font-weight-light; + } + } + } +} diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialCard.stories.tsx b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialCard.stories.tsx new file mode 100644 index 000000000..6262775a9 --- /dev/null +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialCard.stories.tsx @@ -0,0 +1,98 @@ +/* eslint-disable react/function-component-definition, react/destructuring-assignment */ +import React from 'react'; +import { ComponentMeta, ComponentStory } from '@storybook/react'; +import { VerifiableCredentialSchema, VerifiableCredentialStatus } from '@shared/storage/types'; +import { VerifiableCredentialMetadata } from '@shared/utils/verifiable-credential-helpers'; +import { VerifiableCredentialCard } from './VerifiableCredentialCard'; + +export default { + title: 'VerifiableCredential/VerifiableCredentialCard', + component: VerifiableCredentialCard, +} as ComponentMeta; + +const schema: VerifiableCredentialSchema = { + $id: 'https://example-university.com/certificates/JsonSchema2023-education-certificate.json', + $schema: 'https://json-schema.org/draft/2020-12/schema', + name: 'Education certificate', + description: 'Simple representation of an education certificate.', + type: 'object', + properties: { + credentialSubject: { + type: 'object', + properties: { + id: { + title: 'Credential subject id', + type: 'string', + description: 'Credential subject identifier', + }, + degreeType: { + title: 'Degree Hello', + type: 'string', + description: 'Degree type', + }, + degreeName: { + title: 'Degree name', + type: 'string', + description: 'Degree name', + }, + graduationDate: { + title: 'Graduation date', + type: 'string', + format: 'date-time', + description: 'Graduation date', + }, + }, + required: ['id', 'degreeType', 'degreeName', 'graduationDate'], + }, + }, + required: ['credentialSubject'], +}; + +const metadata: VerifiableCredentialMetadata = { + title: 'Education Certificate v2', + logo: { + url: 'https://img.logoipsum.com/298.svg', + hash: '1c74f7eb1b3343a5834e60e9a8fce277f2c7553112accd42e63fae7a09e0caf8', + }, + background_color: '#003d73', + image: { + url: 'https://picsum.photos/327/120', + }, + localization: { + 'da-DK': { + url: 'https://location.of/the/danish/metadata.json', + hash: '624a1a7e51f7a87effbf8261426cb7d436cf597be327ebbf113e62cb7814a34b', + }, + }, +}; + +const verifiableCredential = { + '@context': ['https://www.w3.org/2018/credentials/v1', 'Concordium VC URI'], + id: 'did:ccd:NETWORK:sci:INDEX:SUBINDEX/credentialEntry/ff4aa77af80b4d72973ccb957d180746', + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + issuer: 'did:ccd:NETWORK:sci:INDEX:SUBINDEX/issuer', + issuanceDate: '2010-01-01T00:00:00Z', + credentialSubject: { + id: 'did:ccd:pkc:ebfeb1f712ebc6f1c276e12ec21', + degreeType: 'Bachelor degree', + degreeName: 'Bachelor of Science and Arts', + graduationDate: '2010-06-01T00:00:00Z', + }, + credentialSchema: { + id: 'https://example-university.com/certificates/simple-education-certificate.json', + type: 'CredentialSchema2022', // the same for all schemas + }, +}; + +export const Primary: ComponentStory = () => { + return ( +
+ +
+ ); +}; diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialCard.tsx b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialCard.tsx new file mode 100644 index 000000000..debac7695 --- /dev/null +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialCard.tsx @@ -0,0 +1,140 @@ +import React, { PropsWithChildren } from 'react'; + +import { VerifiableCredentialMetadata } from '@shared/utils/verifiable-credential-helpers'; +import Img from '@popup/shared/Img'; +import { + VerifiableCredential, + VerifiableCredentialStatus, + VerifiableCredentialSchema, + MetadataUrl, +} from '../../../shared/storage/types'; +import StatusIcon from './VerifiableCredentialStatus'; + +function Logo({ logo }: { logo: MetadataUrl }) { + return ; +} + +function DisplayImage({ image }: { image: MetadataUrl }) { + return ( +
+ +
+ ); +} + +/** + * Renders a verifiable credential attribute. + */ +function DisplayAttribute({ + attributeKey, + attributeValue, + attributeTitle, +}: { + attributeKey: string; + attributeValue: string | number; + attributeTitle: string; +}) { + return ( +
+ +
{attributeValue}
+
+ ); +} + +/** + * Wraps children components in a verifiable credential card that is clickable if onClick + * is defined. + */ +function ClickableVerifiableCredential({ + children, + metadata, + onClick, +}: PropsWithChildren<{ metadata: VerifiableCredentialMetadata; onClick?: () => void }>) { + if (onClick) { + return ( +
{ + if (e.key === 'Enter') { + onClick(); + } + }} + role="button" + tabIndex={0} + > + {children} +
+ ); + } + return ( +
+ {children} +
+ ); +} + +/** + * Apply the schema to an attribute, adding the title from the schema, which + * should be displayed to the user. + * @param schema the schema to apply + * @returns the attribute together with its title. + * @throws if there is a mismatch in fields between the credential and the schema, i.e. the schema is invalid. + */ +function applySchema( + schema: VerifiableCredentialSchema +): (value: [string, string | number]) => { title: string; key: string; value: string | number } { + return (value: [string, string | number]) => { + const attributeSchema = schema.properties.credentialSubject.properties[value[0]]; + if (!attributeSchema) { + throw new Error(`Missing attribute schema for key: ${value[0]}`); + } + return { + title: attributeSchema.title, + key: value[0], + value: value[1], + }; + }; +} + +export function VerifiableCredentialCard({ + credential, + schema, + credentialStatus, + metadata, + onClick, +}: { + credential: VerifiableCredential; + schema: VerifiableCredentialSchema; + credentialStatus: VerifiableCredentialStatus; + metadata: VerifiableCredentialMetadata; + onClick?: () => void; +}) { + const attributes = Object.entries(credential.credentialSubject) + .filter((val) => val[0] !== 'id') + .map(applySchema(schema)); + + return ( + +
+ +
{metadata.title}
+ +
+ {metadata.image && } +
+ {attributes && + attributes.map((attribute) => ( + + ))} +
+
+ ); +} diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialHooks.tsx b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialHooks.tsx new file mode 100644 index 000000000..8b8d75f2f --- /dev/null +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialHooks.tsx @@ -0,0 +1,147 @@ +import { grpcClientAtom } from '@popup/store/settings'; +import { VerifiableCredential, VerifiableCredentialSchema, VerifiableCredentialStatus } from '@shared/storage/types'; +import { + CredentialQueryResponse, + VerifiableCredentialMetadata, + getCredentialHolderId, + getCredentialRegistryContractAddress, + getVerifiableCredentialEntry, + getVerifiableCredentialStatus, +} from '@shared/utils/verifiable-credential-helpers'; +import { useAtomValue } from 'jotai'; +import { useEffect, useState } from 'react'; +import { + storedVerifiableCredentialMetadataAtom, + storedVerifiableCredentialSchemasAtom, +} from '@popup/store/verifiable-credential'; +import { AsyncWrapper } from '@popup/store/utils'; +import { ConcordiumGRPCClient } from '@concordium/web-sdk'; + +/** + * Retrieve the on-chain credential status for a verifiable credential in a registry contract. + * @param credential the verifiable credential to lookup the status for + * @returns the status for the given credential + */ +export function useCredentialStatus(credential: VerifiableCredential) { + const [status, setStatus] = useState(); + const client = useAtomValue(grpcClientAtom); + + useEffect(() => { + getVerifiableCredentialStatus(client, credential.id).then((credentialStatus) => { + setStatus(credentialStatus); + }); + }, [credential.id, client]); + + return status; +} + +/** + * Retrieves the schema to be used to render the credential. The schema is found in + * storage and must be available there. + * @param credential the verifiable credential to retrieve the schema for + * @throws if no schema is found in storage for the provided credential + * @returns the credential's schema used for rendering the credential + */ +export function useCredentialSchema(credential: VerifiableCredential) { + const [schema, setSchema] = useState(); + const schemas = useAtomValue(storedVerifiableCredentialSchemasAtom); + + useEffect(() => { + if (!schemas.loading) { + const schemaValue = schemas.value[credential.credentialSchema.id]; + if (!schemaValue) { + throw new Error(`Attempted to find schema for credentialId: ${credential.id} but none was found!`); + } + setSchema(schemaValue); + } + }, [schemas.loading]); + + return schema; +} + +/** + * Retrieve the on-chain credential entry for a verifiable credential in a CIS-4 credential registry contract. + * @param credential the verifiable credential to retrieve the credential entry for + * @returns the credential entry for the given credential, undefined if one is not found yet + */ +export function useCredentialEntry(credential: VerifiableCredential) { + const [credentialEntry, setCredentialEntry] = useState(); + const client = useAtomValue(grpcClientAtom); + + useEffect(() => { + const credentialHolderId = getCredentialHolderId(credential.id); + const registryContractAddress = getCredentialRegistryContractAddress(credential.id); + getVerifiableCredentialEntry(client, registryContractAddress, credentialHolderId).then((entry) => { + setCredentialEntry(entry); + }); + }, [credential.id, client]); + + return credentialEntry; +} + +/** + * Retrieves the credential metadata to be used to render the credential. The credential metadata is + * found in storage and must be available there. + * @param credential the verifiable credential to retrieve the schema for + * @throws if no credential metadata is found in storage for the provided credential + * @returns the credential's metadata used for rendering the credential + */ +export function useCredentialMetadata(credential: VerifiableCredential) { + const [metadata, setMetadata] = useState(); + const credentialEntry = useCredentialEntry(credential); + const storedMetadata = useAtomValue(storedVerifiableCredentialMetadataAtom); + + useEffect(() => { + if (!storedMetadata.loading && credentialEntry) { + const storedCredentialMetadata = storedMetadata.value[credentialEntry.credentialInfo.metadataUrl.url]; + if (!storedCredentialMetadata) { + throw new Error( + `Attempted to find credential metadata for credentialId: ${credential.id} but none was found!` + ); + } + setMetadata(storedCredentialMetadata); + } + }, [storedMetadata, credentialEntry]); + + return metadata; +} + +/** + * Retrieves data and uses the provided data setter to update chrome.storage with the changes found. + * The dataFetcher is responsible for delivering the exact updated picture that should be set. + * @param credentials the credentials to fetch up to date data for + * @param storedData the current stored data (that is to be updated) + * @param setStoredData setter for setting the stored data, should be an atom setter connected to chrome.storage + * @param dataFetcher the function that fetches updated data + */ +export function useFetchingEffect( + credentials: VerifiableCredential[] | undefined, + storedData: AsyncWrapper>, + setStoredData: (update: Record) => Promise, + dataFetcher: ( + credentials: VerifiableCredential[], + client: ConcordiumGRPCClient, + abortControllers: AbortController[], + storedData: Record + ) => Promise<{ data: Record; updateReceived: boolean }> +) { + const client = useAtomValue(grpcClientAtom); + + useEffect(() => { + let isCancelled = false; + const abortControllers: AbortController[] = []; + + if (credentials && !storedData.loading) { + dataFetcher(credentials, client, abortControllers, storedData.value).then((result) => { + if (!isCancelled && result.updateReceived) { + setStoredData(result.data); + } + }); + } + + return () => { + isCancelled = true; + abortControllers.forEach((controller) => controller.abort()); + }; + }, [storedData.loading, credentials, client]); +} diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialList.tsx b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialList.tsx new file mode 100644 index 000000000..199cc5d1e --- /dev/null +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialList.tsx @@ -0,0 +1,130 @@ +import React, { useState } from 'react'; +import { + storedVerifiableCredentialMetadataAtom, + storedVerifiableCredentialSchemasAtom, + storedVerifiableCredentialsAtom, +} from '@popup/store/verifiable-credential'; +import { useAtom, useAtomValue } from 'jotai'; +import { VerifiableCredential, VerifiableCredentialSchema, VerifiableCredentialStatus } from '@shared/storage/types'; +import { + VerifiableCredentialMetadata, + getChangesToCredentialMetadata, + getChangesToCredentialSchemas, +} from '@shared/utils/verifiable-credential-helpers'; +import { + useCredentialMetadata, + useCredentialSchema, + useCredentialStatus, + useFetchingEffect, +} from './VerifiableCredentialHooks'; +import { VerifiableCredentialCard } from './VerifiableCredentialCard'; + +/** + * Component to display when there are no verifiable credentials in the wallet. + */ +function NoVerifiableCredentials() { + return ( +
+

You do not have any verifiable credentials in your wallet.

+
+ ); +} + +function VerifiableCredentialCardWithStatusFromChain({ + credential, + onClick, +}: { + credential: VerifiableCredential; + onClick?: ( + status: VerifiableCredentialStatus, + schema: VerifiableCredentialSchema, + metadata: VerifiableCredentialMetadata + ) => void; +}) { + const status = useCredentialStatus(credential); + const schema = useCredentialSchema(credential); + const metadata = useCredentialMetadata(credential); + + // Render nothing until all the required data is available. + if (!schema || !metadata || status === undefined) { + return null; + } + + return ( + { + if (onClick) { + onClick(status, schema, metadata); + } + }} + credentialStatus={status} + metadata={metadata} + /> + ); +} + +/** + * Renders all verifiable credentials that are in the wallet. The credentials + * are selectable by clicking them, which will move the user to a view containing + * a single credential. + */ +export default function VerifiableCredentialList() { + const verifiableCredentials = useAtomValue(storedVerifiableCredentialsAtom); + const [selected, setSelected] = useState<{ + credential: VerifiableCredential; + status: VerifiableCredentialStatus; + schema: VerifiableCredentialSchema; + metadata: VerifiableCredentialMetadata; + }>(); + const [schemas, setSchemas] = useAtom(storedVerifiableCredentialSchemasAtom); + const [storedMetadata, setStoredMetadata] = useAtom(storedVerifiableCredentialMetadataAtom); + + // Hooks that update the stored credential schemas and stored credential metadata. + useFetchingEffect( + verifiableCredentials, + storedMetadata, + setStoredMetadata, + getChangesToCredentialMetadata + ); + useFetchingEffect( + verifiableCredentials, + schemas, + setSchemas, + getChangesToCredentialSchemas + ); + + if (!verifiableCredentials || !verifiableCredentials.length) { + return ; + } + + if (selected) { + return ( + + ); + } + + return ( +
+ {verifiableCredentials.map((credential) => { + return ( + setSelected({ credential, status, schema, metadata })} + /> + ); + })} +
+ ); +} diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialStatus.tsx b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialStatus.tsx new file mode 100644 index 000000000..7155220f2 --- /dev/null +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/VerifiableCredentialStatus.tsx @@ -0,0 +1,37 @@ +import { VerifiableCredentialStatus } from '@shared/storage/types'; +import React from 'react'; +import RevokedIcon from '@assets/svg/revoked.svg'; +import ActiveIcon from '@assets/svg/verified.svg'; +import ExpiredIcon from '@assets/svg/block.svg'; +import PendingIcon from '@assets/svg/pending.svg'; + +/** + * Component for displaying the status of a verifiable credential. + */ +export default function StatusIcon({ status }: { status: VerifiableCredentialStatus }) { + let icon = null; + switch (status) { + case VerifiableCredentialStatus.Active: + icon = ; + break; + case VerifiableCredentialStatus.Revoked: + icon = ; + break; + case VerifiableCredentialStatus.Expired: + icon = ; + break; + case VerifiableCredentialStatus.NotActivated: + icon = ; + break; + default: + icon = null; + break; + } + + return ( +
+ {VerifiableCredentialStatus[status]} + {icon} +
+ ); +} diff --git a/packages/browser-wallet/src/popup/pages/VerifiableCredential/index.ts b/packages/browser-wallet/src/popup/pages/VerifiableCredential/index.ts new file mode 100644 index 000000000..658eefc9c --- /dev/null +++ b/packages/browser-wallet/src/popup/pages/VerifiableCredential/index.ts @@ -0,0 +1 @@ +export { default } from './VerifiableCredentialList'; diff --git a/packages/browser-wallet/src/popup/shell/Routes.tsx b/packages/browser-wallet/src/popup/shell/Routes.tsx index 85c30f07b..a2d09fbc3 100644 --- a/packages/browser-wallet/src/popup/shell/Routes.tsx +++ b/packages/browser-wallet/src/popup/shell/Routes.tsx @@ -32,6 +32,7 @@ import RecoveryFinish from '@popup/pages/Recovery/RecoveryFinish'; import ChangePasscode from '@popup/pages/ChangePasscode/ChangePasscode'; import AddTokensPrompt from '@popup/pages/ExternalAddTokens/ExternalAddTokens'; import IdProofRequest from '@popup/pages/IdProofRequest'; +import VerifiableCredentialList from '@popup/pages/VerifiableCredential'; import ConnectAccountsRequest from '@popup/pages/ConnectAccountsRequest'; import AllowListRoutes from '@popup/pages/Allowlist'; @@ -195,6 +196,7 @@ export default function Routes() { path={`${relativePath(relativeRoutes.home.path, absoluteRoutes.home.identities.add.path)}/*`} /> } path={relativeRoutes.home.identities.path} /> + } path={relativeRoutes.home.verifiableCredentials.path} /> } /> } path={`${relativeRoutes.home.settings.allowlist.path}/*`} /> diff --git a/packages/browser-wallet/src/popup/store/utils.ts b/packages/browser-wallet/src/popup/store/utils.ts index ebf80b1f8..66a89fa4a 100644 --- a/packages/browser-wallet/src/popup/store/utils.ts +++ b/packages/browser-wallet/src/popup/store/utils.ts @@ -26,12 +26,16 @@ import { sessionCookie, sessionOpenPrompt, storedAcceptedTerms, + storedVerifiableCredentials, + storedVerifiableCredentialSchemas, storedAllowlist, + storedVerifiableCredentialMetadata, } from '@shared/storage/access'; import { ChromeStorageKey } from '@shared/storage/types'; import { atom, PrimitiveAtom, WritableAtom } from 'jotai'; -const accessorMap = { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +const accessorMap: Record> = { [ChromeStorageKey.Identities]: useIndexedStorage(storedIdentities, getGenesisHash), [ChromeStorageKey.SelectedIdentity]: storedSelectedIdentity, [ChromeStorageKey.ConnectedSites]: storedConnectedSites, @@ -56,6 +60,9 @@ const accessorMap = { [ChromeStorageKey.Cookie]: useIndexedStorage(sessionCookie, getGenesisHash), [ChromeStorageKey.OpenPrompt]: sessionOpenPrompt, [ChromeStorageKey.AcceptedTerms]: storedAcceptedTerms, + [ChromeStorageKey.VerifiableCredentials]: useIndexedStorage(storedVerifiableCredentials, getGenesisHash), + [ChromeStorageKey.VerifiableCredentialSchemas]: storedVerifiableCredentialSchemas, + [ChromeStorageKey.VerifiableCredentialMetadata]: storedVerifiableCredentialMetadata, [ChromeStorageKey.Allowlist]: storedAllowlist, }; diff --git a/packages/browser-wallet/src/popup/store/verifiable-credential.ts b/packages/browser-wallet/src/popup/store/verifiable-credential.ts new file mode 100644 index 000000000..3fe0647e4 --- /dev/null +++ b/packages/browser-wallet/src/popup/store/verifiable-credential.ts @@ -0,0 +1,18 @@ +import { ChromeStorageKey, VerifiableCredential, VerifiableCredentialSchema } from '@shared/storage/types'; +import { VerifiableCredentialMetadata } from '@shared/utils/verifiable-credential-helpers'; +import { atomWithChromeStorage } from './utils'; + +export const storedVerifiableCredentialsAtom = atomWithChromeStorage( + ChromeStorageKey.VerifiableCredentials, + [] +); + +export const storedVerifiableCredentialSchemasAtom = atomWithChromeStorage>( + ChromeStorageKey.VerifiableCredentialSchemas, + {}, + true +); + +export const storedVerifiableCredentialMetadataAtom = atomWithChromeStorage< + Record +>(ChromeStorageKey.VerifiableCredentialMetadata, {}, true); diff --git a/packages/browser-wallet/src/popup/styles/_components.scss b/packages/browser-wallet/src/popup/styles/_components.scss index caedb9ad8..d3845a5d2 100644 --- a/packages/browser-wallet/src/popup/styles/_components.scss +++ b/packages/browser-wallet/src/popup/styles/_components.scss @@ -30,6 +30,7 @@ @import '../pages/Settings/Settings'; @import '../pages/Identity/Identity'; @import '../pages/IdentityIssuance/IdentityIssuance'; +@import '../pages/VerifiableCredential/VerifiableCredential'; @import '../pages/About/About'; @import '../pages/Login/Login'; @import '../pages/AddAccount/AddAccount'; diff --git a/packages/browser-wallet/src/shared/storage/access.ts b/packages/browser-wallet/src/shared/storage/access.ts index 33de0f933..206323fc8 100644 --- a/packages/browser-wallet/src/shared/storage/access.ts +++ b/packages/browser-wallet/src/shared/storage/access.ts @@ -1,3 +1,4 @@ +import { VerifiableCredentialMetadata } from '@shared/utils/verifiable-credential-helpers'; import { ChromeStorageKey, EncryptedData, @@ -11,6 +12,8 @@ import { TokenMetadata, TokenStorage, AcceptedTermsState, + VerifiableCredential, + VerifiableCredentialSchema, } from './types'; export type StorageAccessor = { @@ -145,6 +148,18 @@ export const storedTokenMetadata = makeStorageAccessor('local', ChromeStorageKey.AcceptedTerms); +export const storedVerifiableCredentials = makeIndexedStorageAccessor( + 'local', + ChromeStorageKey.VerifiableCredentials +); +export const storedVerifiableCredentialSchemas = makeStorageAccessor>( + 'local', + ChromeStorageKey.VerifiableCredentialSchemas +); +export const storedVerifiableCredentialMetadata = makeStorageAccessor>( + 'local', + ChromeStorageKey.VerifiableCredentialMetadata +); const indexedStoredAllowlist = makeIndexedStorageAccessor>( 'local', ChromeStorageKey.Allowlist diff --git a/packages/browser-wallet/src/shared/storage/types.ts b/packages/browser-wallet/src/shared/storage/types.ts index 1e8f9a43c..1f8fb3caf 100644 --- a/packages/browser-wallet/src/shared/storage/types.ts +++ b/packages/browser-wallet/src/shared/storage/types.ts @@ -25,6 +25,9 @@ export enum ChromeStorageKey { Cookie = 'cookie', OpenPrompt = 'openPrompt', AcceptedTerms = 'acceptedTerms', + VerifiableCredentials = 'verifiableCredentials', + VerifiableCredentialSchemas = 'verifiableCredentialSchemas', + VerifiableCredentialMetadata = 'verifiableCredentialMetadata', Allowlist = 'allowlist', } @@ -254,3 +257,53 @@ export type AcceptedTermsState = { version: string; url?: string; }; + +export enum VerifiableCredentialStatus { + Active, + Revoked, + Expired, + NotActivated, +} + +interface CredentialSchema { + id: string; + type: string; +} + +export type CredentialSubject = { id: string } & Record; + +export interface VerifiableCredential { + '@context': string[]; + id: string; + type: string[]; + issuer: string; + issuanceDate: string; + credentialSubject: CredentialSubject; + credentialSchema: CredentialSchema; +} + +interface CredentialSchemaProperty { + title: string; + type: 'string' | 'number' | string; + description: string; +} + +interface CredentialSchemaSubject { + type: string; + required: string[]; + properties: { id: CredentialSchemaProperty } & Record; +} + +export interface SchemaProperties { + credentialSubject: CredentialSchemaSubject; +} + +export interface VerifiableCredentialSchema { + $id: string; + $schema: string; + name: string; + description: string; + type: string; + properties: SchemaProperties; + required: string[]; +} diff --git a/packages/browser-wallet/src/shared/utils/contract-helpers.ts b/packages/browser-wallet/src/shared/utils/contract-helpers.ts new file mode 100644 index 000000000..031ef5432 --- /dev/null +++ b/packages/browser-wallet/src/shared/utils/contract-helpers.ts @@ -0,0 +1,11 @@ +import { InstanceInfo } from '@concordium/web-sdk'; + +/** + * Get the name of a contract. + * This works as the name in an instance info is prefixed with 'init_'. + * @param instanceInfo the instance info to extract the contract name from + * @returns the contract's name to be used as a prefix when setting the receive name for a contract method + */ +export function getContractName(instanceInfo: InstanceInfo): string | undefined { + return instanceInfo.name.substring(5); +} diff --git a/packages/browser-wallet/src/shared/utils/verifiable-credential-helpers.ts b/packages/browser-wallet/src/shared/utils/verifiable-credential-helpers.ts new file mode 100644 index 000000000..1954af286 --- /dev/null +++ b/packages/browser-wallet/src/shared/utils/verifiable-credential-helpers.ts @@ -0,0 +1,637 @@ +import { ConcordiumGRPCClient, ContractAddress, sha256 } from '@concordium/web-sdk'; +import { + MetadataUrl, + VerifiableCredential, + VerifiableCredentialSchema, + VerifiableCredentialStatus, +} from '@shared/storage/types'; +import { Buffer } from 'buffer/'; +import jsonschema from 'jsonschema'; +import { getContractName } from './contract-helpers'; + +/** + * Extracts the credential holder id from a verifiable credential id (did). + * @param credentialId the did for a credential + * @returns the credential holder id + */ +export function getCredentialHolderId(credentialId: string): string { + const credentialIdParts = credentialId.split('/'); + const credentialHolderId = credentialIdParts[credentialIdParts.length - 1]; + + if (credentialHolderId.length !== 64) { + throw new Error(`Invalid credential holder id found from: ${credentialId}`); + } + + return credentialHolderId; +} + +/** + * Extracts the credential registry contract addres from a verifiable credential id (did). + * @param credentialId the did for a credential + * @returns the contract address of the issuing contract of the provided credential id + */ +export function getCredentialRegistryContractAddress(credentialId: string): ContractAddress { + const credentialIdParts = credentialId.split(':'); + const index = BigInt(credentialIdParts[4]); + const subindex = BigInt(credentialIdParts[5].split('/')[0]); + return { index, subindex }; +} + +function deserializeCredentialStatus(serializedCredentialStatus: string): VerifiableCredentialStatus { + const buff = Buffer.from(serializedCredentialStatus, 'hex'); + switch (buff.readUInt8(0)) { + case 0: + return VerifiableCredentialStatus.Active; + case 1: + return VerifiableCredentialStatus.Revoked; + case 2: + return VerifiableCredentialStatus.Expired; + case 3: + return VerifiableCredentialStatus.NotActivated; + default: + throw new Error(`Received an invalid credential status: ${serializedCredentialStatus}`); + } +} + +/** + * Get the status of a verifiable credential in a CIS-4 contract. + * @param client the GRPC client for accessing a node + * @param credentialId the id for a verifiable credential + * @throws an error if the invoke contract call fails, or if no return value is available + * @returns the status of the verifiable credential, the status will be undefined if the contract is not found + */ +export async function getVerifiableCredentialStatus(client: ConcordiumGRPCClient, credentialId: string) { + const contractAddress = getCredentialRegistryContractAddress(credentialId); + const instanceInfo = await client.getInstanceInfo(contractAddress); + if (instanceInfo === undefined) { + return undefined; + } + + const result = await client.invokeContract({ + contract: contractAddress, + method: `${getContractName(instanceInfo)}.credentialStatus`, + parameter: Buffer.from(getCredentialHolderId(credentialId), 'hex'), + }); + + if (result.tag !== 'success') { + throw new Error(result.reason.tag); + } + + const { returnValue } = result; + if (returnValue === undefined) { + throw new Error(`Return value is missing from credentialStatus result in CIS-4 contract: ${contractAddress}`); + } + + return deserializeCredentialStatus(returnValue); +} + +export interface SchemaRef { + schema: MetadataUrl; +} + +function deserializeUrlChecksumPair(buffer: Buffer, offset: number) { + let localOffset = offset; + const urlLength = buffer.readUInt16LE(localOffset); + localOffset += 2; + + const url = buffer.toString('utf-8', localOffset, localOffset + urlLength); + localOffset += urlLength; + + const containsChecksum = buffer.readUInt8(localOffset); + localOffset += 1; + + let checksum: string | undefined; + if (containsChecksum) { + checksum = buffer.toString('hex', localOffset, localOffset + 32); + localOffset += 32; + } + + return { + url, + checksum, + offset: localOffset, + }; +} + +export interface RegistryMetadata { + issuerMetadata: MetadataUrl; + credentialType: string; + credentialSchema: SchemaRef; +} + +interface MetadataResponse { + issuerMetadata: MetadataUrl; + credentialType: string; + credentialSchema: SchemaRef; +} + +function deserializeRegistryMetadata(serializedRegistryMetadata: string): MetadataResponse { + const buffer = Buffer.from(serializedRegistryMetadata, 'hex'); + let offset = 0; + + const issuerMetadata = deserializeUrlChecksumPair(buffer, offset); + offset = issuerMetadata.offset; + + const typeLength = buffer.readInt8(offset); + offset += 1; + const credentialType = buffer.toString('utf-8', offset, offset + typeLength); + offset += typeLength; + + const credentialSchema = deserializeUrlChecksumPair(buffer, offset); + + return { + issuerMetadata: { url: issuerMetadata.url, hash: issuerMetadata.checksum }, + credentialType, + credentialSchema: { + schema: { url: credentialSchema.url, hash: credentialSchema.checksum }, + }, + }; +} + +/** + * Get the registry metadata from a credential registry CIS-4 contract. + * @param client the GRPC client for accessing a node + * @param contractAddress the address of a CIS-4 contract + * @returns the registry metadata for the contract, or undefined if the contract instance was not found + */ +export async function getCredentialRegistryMetadata(client: ConcordiumGRPCClient, contractAddress: ContractAddress) { + const instanceInfo = await client.getInstanceInfo(contractAddress); + if (instanceInfo === undefined) { + return undefined; + } + + const result = await client.invokeContract({ + contract: contractAddress, + method: `${getContractName(instanceInfo)}.registryMetadata`, + }); + + if (result.tag !== 'success') { + throw new Error(result.reason.tag); + } + + const { returnValue } = result; + if (returnValue === undefined) { + throw new Error(`Return value is missing from credentialStatus result in CIS-4 contract: ${contractAddress}`); + } + + return deserializeRegistryMetadata(returnValue); +} + +// The schemas have been generated using ts-json-schema-generator and their +// corresponding type definitions. +const verifiableCredentialSchemaSchema = { + $ref: '#/definitions/VerifiableCredentialSchema', + $schema: 'http://json-schema.org/draft-07/schema#', + definitions: { + SchemaProperties: { + additionalProperties: false, + properties: { + credentialSubject: { + additionalProperties: false, + properties: { + properties: { + additionalProperties: { + type: 'object', + }, + properties: { + id: { + additionalProperties: false, + properties: { + description: { + type: 'string', + }, + title: { + type: 'string', + }, + type: { + type: 'string', + }, + }, + required: ['title', 'type', 'description'], + type: 'object', + }, + }, + required: ['id'], + type: 'object', + }, + required: { + items: { + type: 'string', + }, + type: 'array', + }, + type: { + type: 'string', + }, + }, + required: ['type', 'required', 'properties'], + type: 'object', + }, + }, + required: ['credentialSubject'], + type: 'object', + }, + VerifiableCredentialSchema: { + additionalProperties: false, + properties: { + $id: { + type: 'string', + }, + $schema: { + type: 'string', + }, + description: { + type: 'string', + }, + name: { + type: 'string', + }, + properties: { + $ref: '#/definitions/SchemaProperties', + }, + required: { + items: { + type: 'string', + }, + type: 'array', + }, + type: { + type: 'string', + }, + }, + required: ['$id', '$schema', 'name', 'description', 'type', 'properties', 'required'], + type: 'object', + }, + }, +}; + +const verifiableCredentialMetadataSchema = { + $ref: '#/definitions/VerifiableCredentialMetadata', + $schema: 'http://json-schema.org/draft-07/schema#', + definitions: { + HexString: { + type: 'string', + }, + MetadataUrl: { + additionalProperties: false, + properties: { + hash: { + $ref: '#/definitions/HexString', + }, + url: { + type: 'string', + }, + }, + required: ['url'], + type: 'object', + }, + VerifiableCredentialMetadata: { + additionalProperties: false, + properties: { + background_color: { + type: 'string', + }, + image: { + $ref: '#/definitions/MetadataUrl', + }, + localization: { + additionalProperties: { + $ref: '#/definitions/MetadataUrl', + }, + type: 'object', + }, + logo: { + $ref: '#/definitions/MetadataUrl', + }, + title: { + type: 'string', + }, + }, + required: ['title', 'logo', 'background_color'], + type: 'object', + }, + }, +}; + +export interface VerifiableCredentialMetadata { + title: string; + logo: MetadataUrl; + background_color: string; + image?: MetadataUrl; + localization?: Record; +} + +export interface CredentialInfo { + credentialHolderId: string; + holderRevocable: boolean; + validFrom: bigint; + validUntil?: bigint; + metadataUrl: MetadataUrl; +} + +export interface CredentialQueryResponse { + credentialInfo: CredentialInfo; + schemaRef: SchemaRef; + revocationNonce: bigint; +} + +/** + * Deserializes a CredentialEntry according to the CIS-4 specification. + * @param serializedCredentialEntry a serialized credential entry as a hex string + */ +export function deserializeCredentialEntry(serializedCredentialEntry: string): CredentialQueryResponse { + const buffer = Buffer.from(serializedCredentialEntry, 'hex'); + let offset = 0; + + const credentialHolderId = buffer.toString('hex', offset, offset + 32); + offset += 32; + + const holderRevocable = Boolean(buffer.readUInt8(offset)); + offset += 1; + + const validFrom = buffer.readBigUInt64LE(offset) as bigint; + offset += 8; + + const containsValidUntil = Boolean(buffer.readUInt8(offset)); + offset += 1; + + let validUntil: bigint | undefined; + if (containsValidUntil) { + validUntil = buffer.readBigUInt64LE(offset) as bigint; + offset += 8; + } + + const metadata = deserializeUrlChecksumPair(buffer, offset); + offset = metadata.offset; + + const schema = deserializeUrlChecksumPair(buffer, offset); + offset = schema.offset; + + const revocationNonce = buffer.readBigInt64LE(offset) as bigint; + offset += 8; + + return { + credentialInfo: { + credentialHolderId, + holderRevocable, + validFrom, + validUntil, + metadataUrl: { url: metadata.url, hash: metadata.checksum }, + }, + schemaRef: { + schema: { url: schema.url, hash: schema.checksum }, + }, + revocationNonce, + }; +} + +/** + * Get a Credential Entry from a CIS-4 contract. + * @param client the GRPC client for accessing a node + * @param contractAddress the address of a CIS-4 contract + * @param credentialHolderId the public key for the credential holder of the entry to retrieve + * @throws an error if the invoke contract call fails, or if no return value is available + * @returns the credential entry which contains data about the credential, undefined if the contract instance is not found + */ +export async function getVerifiableCredentialEntry( + client: ConcordiumGRPCClient, + contractAddress: ContractAddress, + credentialHolderId: string +) { + const instanceInfo = await client.getInstanceInfo(contractAddress); + if (instanceInfo === undefined) { + return undefined; + } + + const result = await client.invokeContract({ + contract: contractAddress, + method: `${getContractName(instanceInfo)}.credentialEntry`, + parameter: Buffer.from(credentialHolderId, 'hex'), + }); + + if (result.tag !== 'success') { + throw new Error(result.reason.tag); + } + + const { returnValue } = result; + if (returnValue === undefined) { + throw new Error(`Return value is missing from credentialEntry result in CIS-4 contract: ${contractAddress}`); + } + + return deserializeCredentialEntry(returnValue); +} + +/** + * Retrieves data from the from the specified URL. The result is validated according + * to the supplied JSON schema. + */ +async function fetchDataFromUrl( + { url, hash }: MetadataUrl, + abortController: AbortController, + jsonSchema: typeof verifiableCredentialMetadataSchema | typeof verifiableCredentialSchemaSchema +): Promise { + const response = await fetch(url, { + headers: new Headers({ 'Access-Control-Allow-Origin': '*' }), + mode: 'cors', + signal: abortController.signal, + }); + + if (!response.ok) { + throw new Error(`Failed to fetch the schema at: ${url}`); + } + + const body = Buffer.from(await response.arrayBuffer()); + if (hash && sha256([body]).toString('hex') !== hash) { + throw new Error(`The content at URL ${url} did not match the provided checksum: ${hash}`); + } + + let bodyAsObject; + let bodyAsString; + try { + bodyAsString = body.toString(); + bodyAsObject = JSON.parse(bodyAsString); + } catch (e) { + throw new Error(`Failed to parse JSON: ${bodyAsString}`); + } + + const validator = new jsonschema.Validator(); + const validationResult = validator.validate(bodyAsObject, jsonSchema); + if (!validationResult.valid) { + throw new Error( + `The received JSON [${bodyAsString}] did not validate according to the schema: ${validationResult.errors}` + ); + } + + return bodyAsObject as T; +} + +/** + * Retrieves a credential schema from the specified URL. + */ +export async function fetchCredentialSchema( + metadata: MetadataUrl, + abortController: AbortController +): Promise { + return fetchDataFromUrl(metadata, abortController, verifiableCredentialSchemaSchema); +} + +/** + * Retrieves credential metadata from the specified URL. + */ +export async function fetchCredentialMetadata( + metadata: MetadataUrl, + abortController: AbortController +): Promise { + return fetchDataFromUrl(metadata, abortController, verifiableCredentialMetadataSchema); +} + +/** + * Retrieves credential schemas for each of the provided credentials. The method ensures + * that duplicate schemas are not fetched multiple times, by only fetching once per + * contract. + * @param credentials the verifiable credentials to get schemas for + * @param client the GRPC client for accessing a node + * @param abortControllers controllers to enable aborting the fetching if needed + * @returns a list of verifiable credential schemas + */ +export async function getCredentialSchemas( + credentials: VerifiableCredential[], + abortControllers: AbortController[], + client: ConcordiumGRPCClient +) { + const onChainSchemas: VerifiableCredentialSchema[] = []; + + const allContractAddresses = credentials.map((vc) => getCredentialRegistryContractAddress(vc.id)); + const issuerContractAddresses = new Set(allContractAddresses); + + for (const contractAddress of issuerContractAddresses) { + let registryMetadata: MetadataResponse | undefined; + try { + registryMetadata = await getCredentialRegistryMetadata(client, contractAddress); + } catch (e) { + throw new Error(`Failed to get registry metadata for contract: ${contractAddress.index} with error: ${e}`); + } + + if (registryMetadata) { + const controller = new AbortController(); + abortControllers.push(controller); + try { + const credentialSchema = await fetchCredentialSchema( + registryMetadata.credentialSchema.schema, + controller + ); + onChainSchemas.push(credentialSchema); + } catch (e) { + // Ignore errors that occur because we aborted, as that is expected to happen. + if (!controller.signal.aborted) { + // TODO This should be logged. + } + } + } + } + + return onChainSchemas; +} + +/** + * Retrieves credential metadata for each of the provided credentials. The method ensures + * that duplicate metadata (metadata hosted at the same URL) is not fetched multiple + * times. + * @param client the GRPC client for accessing a node + * @param credentials the verifiable credentials to get metadata for + * @param abortControllers controllers to enable aborting the fetching if needed + * @returns a list of pairs of verifiable credential metadata and the URL they were fetched from (their key) + */ +export async function getCredentialMetadata( + credentials: VerifiableCredential[], + client: ConcordiumGRPCClient, + abortControllers: AbortController[] +) { + const metadataUrls: MetadataUrl[] = []; + for (const vc of credentials) { + const entry = await getVerifiableCredentialEntry( + client, + getCredentialRegistryContractAddress(vc.id), + getCredentialHolderId(vc.id) + ); + if (entry) { + metadataUrls.push(entry.credentialInfo.metadataUrl); + } + } + + // We filter any duplicate URLs. Note that there could be metadata pairs (url, hash) with the + // same URL but separate hashes that are thrown away here. This is done intentionally for now, + // as the assumption is that that would be a rare situation. This means that the first instance + // of the URL is the one used for gathering the credential metadata. + const uniqueMetadataUrls = [...new Map(metadataUrls.map((item) => [item.url, item])).values()]; + + const metadataList: { metadata: VerifiableCredentialMetadata; url: string }[] = []; + for (const metadataUrl of uniqueMetadataUrls) { + const controller = new AbortController(); + abortControllers.push(controller); + try { + const metadata = await fetchCredentialMetadata(metadataUrl, controller); + metadataList.push({ metadata, url: metadataUrl.url }); + } catch (e) { + // Ignore errors that occur because we aborted, as that is expected to happen. + if (!controller.signal.aborted) { + // TODO This should be logged. + } + } + } + + return metadataList; +} + +export async function getChangesToCredentialMetadata( + credentials: VerifiableCredential[], + client: ConcordiumGRPCClient, + abortControllers: AbortController[], + storedMetadata: Record +) { + const upToDateCredentialMetadata = await getCredentialMetadata(credentials, client, abortControllers); + let updatedStoredMetadata = { ...storedMetadata }; + let updateReceived = false; + + for (const updatedMetadata of upToDateCredentialMetadata) { + if (storedMetadata.value === undefined) { + updatedStoredMetadata = { + [updatedMetadata.url]: updatedMetadata.metadata, + }; + updateReceived = true; + } else { + updatedStoredMetadata[updatedMetadata.url] = updatedMetadata.metadata; + if (JSON.stringify(storedMetadata[updatedMetadata.url]) !== JSON.stringify(updatedMetadata.metadata)) { + updateReceived = true; + } + } + } + + return { data: updatedStoredMetadata, updateReceived }; +} + +export async function getChangesToCredentialSchemas( + credentials: VerifiableCredential[], + client: ConcordiumGRPCClient, + abortControllers: AbortController[], + storedSchemas: Record +) { + const upToDateSchemas = await getCredentialSchemas(credentials, abortControllers, client); + let updatedSchemasInStorage = { ...storedSchemas }; + let updateReceived = false; + + for (const updatedSchema of upToDateSchemas) { + if (storedSchemas === undefined) { + updatedSchemasInStorage = { + [updatedSchema.$id]: updatedSchema, + }; + updateReceived = true; + } else { + updatedSchemasInStorage[updatedSchema.$id] = updatedSchema; + if (JSON.stringify(storedSchemas[updatedSchema.$id]) !== JSON.stringify(updatedSchema)) { + updateReceived = true; + } + } + } + return { data: updatedSchemasInStorage, updateReceived }; +} diff --git a/packages/browser-wallet/test/verifiable-credential-helpers.test.ts b/packages/browser-wallet/test/verifiable-credential-helpers.test.ts new file mode 100644 index 000000000..fe8a57d3f --- /dev/null +++ b/packages/browser-wallet/test/verifiable-credential-helpers.test.ts @@ -0,0 +1,34 @@ +import { + getCredentialHolderId, + getCredentialRegistryContractAddress, +} from '../src/shared/utils/verifiable-credential-helpers'; + +test('credential holder id is extracted from verifiable credential id field', () => { + const id = + 'did:ccd:mainnet:sci:4718:0/credentialEntry/2eec102b173118dda466411fc7df88093788a34c3e2a4b0a8891f5c671a9d106'; + expect(getCredentialHolderId(id)).toEqual('2eec102b173118dda466411fc7df88093788a34c3e2a4b0a8891f5c671a9d106'); +}); + +test('an error is thrown if credential holder id has invalid length', () => { + const id = + 'did:ccd:mainnet:sci:4718:0/credentialEntry/2eec102b173118dda466411fc7df88093788a34c3e4b0a8891f5c671a9d106'; + expect(() => getCredentialHolderId(id)).toThrow(Error); +}); + +test('credentialHolderId: an error is thrown if the credential id is invalid', () => { + const id = + 'did:ccd:mainnet:sci:4718:0credentialEntry2eec102b173118dda466411fc7df88093788a34c3e4b0a8891f5c671a9d106'; + expect(() => getCredentialHolderId(id)).toThrow(Error); +}); + +test('registry contract address is extracted from verifiable credential id field', () => { + const id = + 'did:ccd:mainnet:sci:4718:0/credentialEntry/2eec102b173118dda466411fc7df88093788a34c3e2a4b0a8891f5c671a9d106'; + expect(getCredentialRegistryContractAddress(id)).toEqual({ index: BigInt(4718), subindex: BigInt(0) }); +}); + +test('credentialRegistryContractAddress: an error is thrown if the credential id is invalid', () => { + const id = + 'did:ccd:mainnet:sci:4718:0credentialEntry2eec102b173118dda466411fc7df88093788a34c3e4b0a8891f5c671a9d106'; + expect(() => getCredentialRegistryContractAddress(id)).toThrow(Error); +}); diff --git a/yarn.lock b/yarn.lock index 07a811319..9bf6430f1 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2001,6 +2001,7 @@ __metadata: jest-chrome: ^0.8.0 jotai: ^1.6.5 json-bigint: ^1.0.0 + jsonschema: ^1.4.1 leb128: ^0.0.5 lodash.debounce: ^4.0.8 lodash.groupby: ^4.6.0 @@ -15306,6 +15307,13 @@ __metadata: languageName: node linkType: hard +"jsonschema@npm:^1.4.1": + version: 1.4.1 + resolution: "jsonschema@npm:1.4.1" + checksum: 1ef02a6cd9bc32241ec86bbf1300bdbc3b5f2d8df6eb795517cf7d1cd9909e7beba1e54fdf73990fd66be98a182bda9add9607296b0cb00b1348212988e424b2 + languageName: node + linkType: hard + "jsx-ast-utils@npm:^2.4.1 || ^3.0.0, jsx-ast-utils@npm:^3.2.1": version: 3.3.1 resolution: "jsx-ast-utils@npm:3.3.1"