From 0c4a093ea93cba6845d45cb8d07a4fa616232330 Mon Sep 17 00:00:00 2001 From: Martin Auer Date: Fri, 25 Oct 2024 15:46:14 +0200 Subject: [PATCH] feat: openid4vci mdoc-issuance alpha Signed-off-by: Martin Auer --- demo-openid/src/Holder.ts | 6 + demo-openid/src/Issuer.ts | 56 +++++- packages/core/src/modules/mdoc/MdocOptions.ts | 13 ++ packages/core/src/modules/mdoc/MdocService.ts | 26 +++ .../OpenId4VciHolderService.ts | 30 +++ .../OpenId4VciHolderServiceOptions.ts | 2 + .../OpenId4VcIssuerService.ts | 38 ++++ .../OpenId4VcIssuerServiceOptions.ts | 11 +- .../OpenId4VcSiopVerifierService.ts | 3 + .../src/shared/issuerMetadataUtils.ts | 23 ++- .../OpenId4VciCredentialFormatProfile.ts | 1 + packages/openid4vc/src/shared/transform.ts | 12 +- .../openid4vc/tests/openid4vc.e2e.test.ts | 172 +++++++++++++++++- packages/openid4vc/tests/utilsVci.ts | 10 + pnpm-lock.yaml | 6 +- 15 files changed, 398 insertions(+), 11 deletions(-) diff --git a/demo-openid/src/Holder.ts b/demo-openid/src/Holder.ts index 09187f59ab..6b3d5ab499 100644 --- a/demo-openid/src/Holder.ts +++ b/demo-openid/src/Holder.ts @@ -29,6 +29,12 @@ export class Holder extends BaseAgent> const holder = new Holder(3000, 'OpenId4VcHolder ' + Math.random().toString()) await holder.initializeAgent('96213c3d7fc8d4d6754c7a0fd969598e') + // Set trusted issuer certificates. Required fro verifying mdoc credentials + const trustedCertificates: string[] = [] + await holder.agent.x509.setTrustedCertificates( + trustedCertificates.length === 0 ? undefined : (trustedCertificates as [string, ...string[]]) + ) + return holder } diff --git a/demo-openid/src/Issuer.ts b/demo-openid/src/Issuer.ts index 410080e647..20a7bbcc18 100644 --- a/demo-openid/src/Issuer.ts +++ b/demo-openid/src/Issuer.ts @@ -4,6 +4,7 @@ import type { OpenId4VcCredentialHolderDidBinding, OpenId4VciCredentialRequestToCredentialMapper, OpenId4VciCredentialSupportedWithId, + OpenId4VciSignMdocCredential, OpenId4VcIssuerRecord, } from '@credo-ts/openid4vc' @@ -16,6 +17,10 @@ import { W3cCredentialSubject, W3cIssuer, w3cDate, + X509Service, + KeyType, + X509ModuleConfig, + MdocService, } from '@credo-ts/core' import { OpenId4VcIssuerModule, OpenId4VciCredentialFormatProfile } from '@credo-ts/openid4vc' import { ariesAskar } from '@hyperledger/aries-askar-nodejs' @@ -42,10 +47,17 @@ export const universityDegreeCredentialSdJwt = { vct: 'UniversityDegreeCredential', } satisfies OpenId4VciCredentialSupportedWithId +export const universityDegreeCredentialMdoc = { + id: 'UniversityDegreeCredential-mdoc', + format: OpenId4VciCredentialFormatProfile.MsoMdoc, + doctype: 'UniversityDegreeCredential', +} satisfies OpenId4VciCredentialSupportedWithId + export const credentialsSupported = [ universityDegreeCredential, openBadgeCredential, universityDegreeCredentialSdJwt, + universityDegreeCredentialMdoc, ] satisfies OpenId4VciCredentialSupportedWithId[] function getCredentialRequestToCredentialMapper({ @@ -53,7 +65,11 @@ function getCredentialRequestToCredentialMapper({ }: { issuerDidKey: DidKey }): OpenId4VciCredentialRequestToCredentialMapper { - return async ({ holderBinding, credentialConfigurationIds }) => { + return async ({ holderBinding, credentialConfigurationIds, agentContext }) => { + const trustedCertificates = agentContext.dependencyManager.resolve(X509ModuleConfig).trustedCertificates + if (trustedCertificates?.length !== 1) { + throw new Error(`Expected exactly one trusted certificate. Received ${trustedCertificates?.length}.`) + } const credentialConfigurationId = credentialConfigurationIds[0] if (credentialConfigurationId === universityDegreeCredential.id) { @@ -110,6 +126,25 @@ function getCredentialRequestToCredentialMapper({ } } + if (credentialConfigurationId === universityDegreeCredentialMdoc.id) { + const holderKey = await agentContext.dependencyManager + .resolve(MdocService) + .getKeyFromMdocCredentialHolderBinding(agentContext, holderBinding) + + return { + credentialSupportedId: universityDegreeCredentialMdoc.id, + format: ClaimFormat.MsoMdoc, + docType: universityDegreeCredentialMdoc.doctype, + issuerCertificate: trustedCertificates[0], + holderKey, + namespaces: { + 'Leopold-Franzens-University': { + degree: 'bachelor', + }, + }, + } satisfies OpenId4VciSignMdocCredential + } + throw new Error('Invalid request') } } @@ -147,6 +182,25 @@ export class Issuer extends BaseAgent<{ public static async build(): Promise { const issuer = new Issuer(2000, 'OpenId4VcIssuer ' + Math.random().toString()) await issuer.initializeAgent('96213c3d7fc8d4d6754c7a0fd969598f') + + const currentDate = new Date() + currentDate.setDate(currentDate.getDate() - 1) + const nextDay = new Date(currentDate) + nextDay.setDate(currentDate.getDate() + 2) + + const selfSignedCertificate = await X509Service.createSelfSignedCertificate(issuer.agent.context, { + key: await issuer.agent.context.wallet.createKey({ keyType: KeyType.P256 }), + notBefore: currentDate, + notAfter: nextDay, + extensions: [], + name: 'C=DE', + }) + + const issuerCertficicate = selfSignedCertificate.toString('pem') + await issuer.agent.x509.setTrustedCertificates([issuerCertficicate]) + console.log('Set the following certficate for the holder to verify mdoc credentials.') + console.log(issuerCertficicate) + issuer.issuerRecord = await issuer.agent.modules.openId4VcIssuer.createIssuer({ credentialsSupported, }) diff --git a/packages/core/src/modules/mdoc/MdocOptions.ts b/packages/core/src/modules/mdoc/MdocOptions.ts index e6290c6fd4..affa83831b 100644 --- a/packages/core/src/modules/mdoc/MdocOptions.ts +++ b/packages/core/src/modules/mdoc/MdocOptions.ts @@ -1,4 +1,5 @@ import type { Mdoc } from './Mdoc' +import type { Jwk } from '../../crypto' import type { Key } from '../../crypto/Key' import type { DifPresentationExchangeDefinition } from '../dif-presentation-exchange' import type { ValidityInfo, MdocNameSpaces } from '@protokoll/mdoc-client' @@ -56,3 +57,15 @@ export type MdocSignOptions = { issuerCertificate: string holderKey: Key } + +export type MdocCredentialHolderDidBinding = { + method: 'did' + didUrl: string +} + +export type MdocCredentialHolderJwkBinding = { + method: 'jwk' + jwk: Jwk +} + +export type MdocCredentialHolderBinding = MdocCredentialHolderDidBinding | MdocCredentialHolderJwkBinding diff --git a/packages/core/src/modules/mdoc/MdocService.ts b/packages/core/src/modules/mdoc/MdocService.ts index b60b7eeed3..cb83f042d8 100644 --- a/packages/core/src/modules/mdoc/MdocService.ts +++ b/packages/core/src/modules/mdoc/MdocService.ts @@ -3,12 +3,15 @@ import type { MdocDeviceResponseOpenId4VpOptions, MdocDeviceResponseVerifyOptions, MdocVerifyOptions, + MdocCredentialHolderBinding, } from './MdocOptions' import type { Query, QueryOptions } from '../../storage/StorageService' import { injectable } from 'tsyringe' import { AgentContext } from '../../agent' +import { Key } from '../../crypto' +import { DidResolverService, getKeyFromVerificationMethod, parseDid } from '../dids' import { Mdoc } from './Mdoc' import { MdocDeviceResponse } from './MdocDeviceResponse' @@ -37,6 +40,29 @@ export class MdocService { return await mdoc.verify(agentContext, options) } + public async getKeyFromMdocCredentialHolderBinding( + agentContext: AgentContext, + holderBinding: MdocCredentialHolderBinding + ) { + let holderKey: Key + if (holderBinding.method !== 'jwk') { + const parsedDid = parseDid(holderBinding.didUrl) + if (!parsedDid.fragment) { + throw new Error( + `didUrl '${holderBinding.didUrl}' does not contain a '#'. Unable to derive key from did document` + ) + } + + const didResolver = agentContext.dependencyManager.resolve(DidResolverService) + const didDocument = await didResolver.resolveDidDocument(agentContext, holderBinding.didUrl) + holderKey = getKeyFromVerificationMethod(didDocument.dereferenceKey(holderBinding.didUrl, ['assertionMethod'])) + } else { + holderKey = holderBinding.jwk.key + } + + return holderKey + } + public async createOpenId4VpDeviceResponse(agentContext: AgentContext, options: MdocDeviceResponseOpenId4VpOptions) { return MdocDeviceResponse.createOpenId4VpDeviceResponse(agentContext, options) } diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts index d1c5ef5f9d..6617204056 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderService.ts @@ -22,6 +22,7 @@ import type { AuthorizationDetails, AuthorizationDetailsJwtVcJson, AuthorizationDetailsJwtVcJsonLdAndLdpVc, + AuthorizationDetailsMsoMdoc, AuthorizationDetailsSdJwtVc, CredentialResponse, Jwt, @@ -37,6 +38,8 @@ import { Jwk, JwsService, Logger, + Mdoc, + MdocApi, SdJwtVcApi, SignatureSuiteRegistry, TypedArrayEncoder, @@ -178,6 +181,14 @@ export class OpenId4VciHolderService { vct: offeredCredential.vct, claims: offeredCredential.claims, } satisfies AuthorizationDetailsSdJwtVc + } else if (format === OpenId4VciCredentialFormatProfile.MsoMdoc) { + return { + type, + format, + locations, + claims: offeredCredential.claims, + doctype: offeredCredential.doctype, + } satisfies AuthorizationDetailsMsoMdoc } else { throw new CredoError(`Cannot create authorization_details. Unsupported credential format '${format}'.`) } @@ -662,6 +673,7 @@ export class OpenId4VciHolderService { case OpenId4VciCredentialFormatProfile.JwtVcJson: case OpenId4VciCredentialFormatProfile.JwtVcJsonLd: case OpenId4VciCredentialFormatProfile.SdJwtVc: + case OpenId4VciCredentialFormatProfile.MsoMdoc: signatureAlgorithm = options.possibleProofOfPossessionSignatureAlgorithms.find((signatureAlgorithm) => proofSigningAlgsSupported.includes(signatureAlgorithm) ) @@ -782,6 +794,24 @@ export class OpenId4VciHolderService { } return { credential, notificationMetadata } + } else if (format === OpenId4VciCredentialFormatProfile.MsoMdoc) { + if (typeof credentialResponse.successBody.credential !== 'string') + throw new CredoError( + `Received a credential of format ${ + OpenId4VciCredentialFormatProfile.MsoMdoc + }, but the credential is not a string. ${JSON.stringify(credentialResponse.successBody.credential)}` + ) + + const mdocApi = agentContext.dependencyManager.resolve(MdocApi) + const mdoc = Mdoc.fromBase64Url(credentialResponse.successBody.credential) + const verificationResult = await mdocApi.verify(mdoc, {}) + + if (!verificationResult.isValid) { + agentContext.config.logger.error('Failed to validate credential', { verificationResult }) + throw new CredoError(`Failed to validate mdoc credential. Results = ${verificationResult.error}`) + } + + return { credential: mdoc, notificationMetadata } } throw new CredoError(`Unsupported credential format ${credentialResponse.successBody.format}`) diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderServiceOptions.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderServiceOptions.ts index 0bd7ad0e8d..bc75187add 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderServiceOptions.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4VciHolderServiceOptions.ts @@ -21,12 +21,14 @@ export type OpenId4VciSupportedCredentialFormats = | OpenId4VciCredentialFormatProfile.JwtVcJsonLd | OpenId4VciCredentialFormatProfile.SdJwtVc | OpenId4VciCredentialFormatProfile.LdpVc + | OpenId4VciCredentialFormatProfile.MsoMdoc export const openId4VciSupportedCredentialFormats: OpenId4VciSupportedCredentialFormats[] = [ OpenId4VciCredentialFormatProfile.JwtVcJson, OpenId4VciCredentialFormatProfile.JwtVcJsonLd, OpenId4VciCredentialFormatProfile.SdJwtVc, OpenId4VciCredentialFormatProfile.LdpVc, + OpenId4VciCredentialFormatProfile.MsoMdoc, ] export interface OpenId4VciNotificationMetadata { diff --git a/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerService.ts b/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerService.ts index 0cfa754767..f59fd0f75e 100644 --- a/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerService.ts +++ b/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerService.ts @@ -6,6 +6,7 @@ import type { OpenId4VcIssuerMetadata, OpenId4VciSignSdJwtCredential, OpenId4VciSignW3cCredential, + OpenId4VciSignMdocCredential, } from './OpenId4VcIssuerServiceOptions' import type { OpenId4VcIssuanceSessionRecord } from './repository' import type { @@ -47,6 +48,7 @@ import { KeyType, utils, W3cCredentialService, + MdocApi, } from '@credo-ts/core' import { VcIssuerBuilder } from '@sphereon/oid4vci-issuer' @@ -499,6 +501,11 @@ export class OpenId4VcIssuerService { offeredCredential.format === credentialRequest.format ) { return offeredCredential.vct === credentialRequest.vct + } else if ( + credentialRequest.format === OpenId4VciCredentialFormatProfile.MsoMdoc && + offeredCredential.format === credentialRequest.format + ) { + return offeredCredential.doctype === credentialRequest.doctype } return false @@ -518,6 +525,18 @@ export class OpenId4VcIssuerService { } } + private getMsoMdocCredentialSigningCallback = ( + agentContext: AgentContext, + options: OpenId4VciSignMdocCredential + ): CredentialSignerCallback => { + return async () => { + const mdocApi = agentContext.dependencyManager.resolve(MdocApi) + + const mdoc = await mdocApi.sign(options) + return getSphereonVerifiableCredential(mdoc) + } + } + private getW3cCredentialSigningCallback = ( agentContext: AgentContext, options: OpenId4VciSignW3cCredential @@ -712,6 +731,25 @@ export class OpenId4VcIssuerService { credential: { ...signOptions.payload } as unknown as CredentialIssuanceInput, signCallback: this.getSdJwtVcCredentialSigningCallback(agentContext, signOptions), } + } else if (signOptions.format === ClaimFormat.MsoMdoc) { + if (credentialRequest.format !== OpenId4VciCredentialFormatProfile.MsoMdoc) { + throw new CredoError( + `Invalid credential format. Expected '${OpenId4VciCredentialFormatProfile.MsoMdoc}', received '${credentialRequest.format}'.` + ) + } + + if (credentialRequest.doctype !== signOptions.docType) { + throw new CredoError( + `The types of the offered credentials do not match the types of the requested credential. Offered '${signOptions.docType}' Requested '${credentialRequest.doctype}'.` + ) + } + + return { + format: credentialRequest.format, + // NOTE: we don't use the credential value here as we pass the credential directly to the singer + credential: { ...signOptions.namespaces, docType: signOptions.docType } as unknown as CredentialIssuanceInput, + signCallback: this.getMsoMdocCredentialSigningCallback(agentContext, signOptions), + } } else { throw new CredoError(`Unsupported credential format ${signOptions.format}`) } diff --git a/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerServiceOptions.ts b/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerServiceOptions.ts index 58c492abe8..0067a29bc8 100644 --- a/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerServiceOptions.ts +++ b/packages/openid4vc/src/openid4vc-issuer/OpenId4VcIssuerServiceOptions.ts @@ -20,6 +20,7 @@ import type { W3cCredential, SdJwtVcSignOptions, JwaSignatureAlgorithm, + MdocSignOptions, } from '@credo-ts/core' export interface OpenId4VciPreAuthorizedCodeFlowConfig { @@ -139,13 +140,21 @@ export type OpenId4VciCredentialRequestToCredentialMapper = (options: { credentialConfigurationIds: [string, ...string[]] }) => Promise | OpenId4VciSignCredential -export type OpenId4VciSignCredential = OpenId4VciSignSdJwtCredential | OpenId4VciSignW3cCredential +export type OpenId4VciSignCredential = + | OpenId4VciSignSdJwtCredential + | OpenId4VciSignW3cCredential + | OpenId4VciSignMdocCredential export interface OpenId4VciSignSdJwtCredential extends SdJwtVcSignOptions { credentialSupportedId: string format: ClaimFormat.SdJwtVc | `${ClaimFormat.SdJwtVc}` } +export interface OpenId4VciSignMdocCredential extends MdocSignOptions { + credentialSupportedId: string + format: ClaimFormat.MsoMdoc | `${ClaimFormat.MsoMdoc}` +} + export interface OpenId4VciSignW3cCredential { credentialSupportedId: string format: ClaimFormat.JwtVc | `${ClaimFormat.JwtVc}` | ClaimFormat.LdpVc | `${ClaimFormat.LdpVc}` diff --git a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts index baff7edb7f..266fa58a8b 100644 --- a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts +++ b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts @@ -554,6 +554,9 @@ export class OpenId4VcSiopVerifierService { responseTypesSupported: [ResponseType.VP_TOKEN], subject_syntax_types_supported: supportedDidMethods.map((m) => `did:${m}`), vpFormatsSupported: { + mso_mdoc: { + alg: supportedAlgs, + }, jwt_vc: { alg: supportedAlgs, }, diff --git a/packages/openid4vc/src/shared/issuerMetadataUtils.ts b/packages/openid4vc/src/shared/issuerMetadataUtils.ts index 09622cd404..81bf5f42f8 100644 --- a/packages/openid4vc/src/shared/issuerMetadataUtils.ts +++ b/packages/openid4vc/src/shared/issuerMetadataUtils.ts @@ -39,6 +39,13 @@ export function getTypesFromCredentialSupported( ) } return credentialSupported.vct ? [credentialSupported.vct] : undefined + } else if (credentialSupported.format === 'mso_mdoc') { + if (!credentialSupported.doctype) { + throw Error( + `Unable to extract types from credentials supported for format ${credentialSupported.format}. Doctype is not defined` + ) + } + return [credentialSupported.doctype] } throw Error(`Unable to extract types from credentials supported. Unknown format ${credentialSupported.format}`) @@ -57,7 +64,14 @@ export function credentialConfigurationSupportedToCredentialSupported( order: config.order, } - if (config.format === 'jwt_vc_json' || config.format === 'jwt_vc') { + if (config.format === 'mso_mdoc') { + return { + ...baseConfig, + doctype: config.doctype, + format: config.format, + claims: config.claims, + } + } else if (config.format === 'jwt_vc_json' || config.format === 'jwt_vc') { return { ...baseConfig, format: config.format, @@ -151,6 +165,13 @@ export function credentialSupportedToCredentialConfigurationSupported( vct: credentialSupported.vct, claims: credentialSupported.claims, } + } else if (credentialSupported.format === 'mso_mdoc') { + return { + ...baseCredentialConfigurationSupported, + format: credentialSupported.format, + doctype: credentialSupported.doctype, + claims: credentialSupported.claims, + } } throw new CredoError(`Unsupported credential format ${credentialSupported.format}`) diff --git a/packages/openid4vc/src/shared/models/OpenId4VciCredentialFormatProfile.ts b/packages/openid4vc/src/shared/models/OpenId4VciCredentialFormatProfile.ts index 628e65c12e..4d74512986 100644 --- a/packages/openid4vc/src/shared/models/OpenId4VciCredentialFormatProfile.ts +++ b/packages/openid4vc/src/shared/models/OpenId4VciCredentialFormatProfile.ts @@ -3,4 +3,5 @@ export enum OpenId4VciCredentialFormatProfile { JwtVcJsonLd = 'jwt_vc_json-ld', LdpVc = 'ldp_vc', SdJwtVc = 'vc+sd-jwt', + MsoMdoc = 'mso_mdoc', } diff --git a/packages/openid4vc/src/shared/transform.ts b/packages/openid4vc/src/shared/transform.ts index d73cfa638c..a1f4ff2a94 100644 --- a/packages/openid4vc/src/shared/transform.ts +++ b/packages/openid4vc/src/shared/transform.ts @@ -20,7 +20,7 @@ import { export function getSphereonVerifiableCredential( verifiableCredential: VerifiableCredential -): SphereonW3cVerifiableCredential | SphereonCompactSdJwtVc { +): SphereonW3cVerifiableCredential | SphereonCompactSdJwtVc | string { // encoded sd-jwt or jwt if (typeof verifiableCredential === 'string') { return verifiableCredential @@ -29,7 +29,7 @@ export function getSphereonVerifiableCredential( } else if (verifiableCredential instanceof W3cJwtVerifiableCredential) { return verifiableCredential.serializedJwt } else if (verifiableCredential instanceof Mdoc) { - throw new CredoError('Mdoc verifiable credential is not yet supported.') + return verifiableCredential.base64Url } else { return verifiableCredential.compact } @@ -47,6 +47,9 @@ export function getSphereonVerifiablePresentation( return verifiablePresentation.serializedJwt } else if (verifiablePresentation instanceof MdocVerifiablePresentation) { throw new CredoError('Mdoc verifiable presentation is not yet supported.') + + // TODO: CHECK IF THIS IS WHAT IS EXPECTED + // return verifiablePresentation.deviceSignedBase64Url } else { return verifiablePresentation.compact } @@ -73,6 +76,11 @@ export function getVerifiablePresentationFromSphereonWrapped( payload: wrappedVerifiablePresentation.presentation.signedPayload, prettyClaims: wrappedVerifiablePresentation.presentation.decodedPayload, } satisfies SdJwtVc + } else if (wrappedVerifiablePresentation.format === 'mso_mdoc') { + if (typeof wrappedVerifiablePresentation.original !== 'string') { + throw new CredoError('Invalid format of original verifiable presentation. DeviceResponseCbor is not supported') + } + return new MdocVerifiablePresentation(wrappedVerifiablePresentation.original) } throw new CredoError(`Unsupported presentation format: ${wrappedVerifiablePresentation.format}`) diff --git a/packages/openid4vc/tests/openid4vc.e2e.test.ts b/packages/openid4vc/tests/openid4vc.e2e.test.ts index 1409395523..59e181a1af 100644 --- a/packages/openid4vc/tests/openid4vc.e2e.test.ts +++ b/packages/openid4vc/tests/openid4vc.e2e.test.ts @@ -1,6 +1,7 @@ import type { AgentType, TenantType } from './utils' +import type { OpenId4VciSignMdocCredential } from '../src' import type { OpenId4VciCredentialBindingResolver } from '../src/openid4vc-holder' -import type { DifPresentationExchangeDefinitionV2, SdJwtVc } from '@credo-ts/core' +import type { DifPresentationExchangeDefinitionV2, Mdoc, SdJwtVc } from '@credo-ts/core' import type { Server } from 'http' import { @@ -20,6 +21,8 @@ import { KeyType, Jwt, Jwk, + X509ModuleConfig, + MdocService, } from '@credo-ts/core' import express, { type Express } from 'express' @@ -43,6 +46,7 @@ import { } from './utils' import { universityDegreeCredentialConfigurationSupported, + universityDegreeCredentialConfigurationSupportedMdoc, universityDegreeCredentialSdJwt, universityDegreeCredentialSdJwt2, } from './utilsVci' @@ -99,6 +103,11 @@ describe('OpenId4Vc', () => { throw new Error('No verification method found') } + const trustedCertificates = agentContext.dependencyManager.resolve(X509ModuleConfig).trustedCertificates + if (trustedCertificates?.length !== 1) { + throw new Error('Expected exactly one trusted certificate. Received 0.') + } + if (credentialRequest.format === 'vc+sd-jwt') { return { credentialSupportedId: @@ -114,9 +123,24 @@ describe('OpenId4Vc', () => { }, disclosureFrame: { _sd: ['university', 'degree'] }, } + } else if (credentialRequest.format === 'mso_mdoc') { + const mdocService = agentContext.dependencyManager.resolve(MdocService) + const holderKey = await mdocService.getKeyFromMdocCredentialHolderBinding(agentContext, holderBinding) + return { + credentialSupportedId: '', + format: ClaimFormat.MsoMdoc, + docType: universityDegreeCredentialConfigurationSupportedMdoc.doctype, + issuerCertificate: trustedCertificates[0], + holderKey, + namespaces: { + 'Leopold-Franzens-University': { + degree: 'bachelor', + }, + }, + } satisfies OpenId4VciSignMdocCredential + } else { + throw new Error('Invalid request') } - - throw new Error('Invalid request') }, }, }, @@ -1584,4 +1608,146 @@ describe('OpenId4Vc', () => { ], }) }) + + it('e2e flow with tenants, issuer endpoints requesting a mdoc', async () => { + const issuerTenant1 = await issuer.agent.modules.tenants.getTenantAgent({ tenantId: issuer1.tenantId }) + + const currentDate = new Date() + currentDate.setDate(currentDate.getDate() - 1) + const nextDay = new Date(currentDate) + nextDay.setDate(currentDate.getDate() + 2) + + const selfSignedIssuerCertificate = await issuerTenant1.x509.createSelfSignedCertificate({ + key: await issuerTenant1.wallet.createKey({ keyType: KeyType.P256 }), + notBefore: currentDate, + notAfter: nextDay, + extensions: [], + name: 'C=DE', + }) + const selfSignedIssuerCertPem = selfSignedIssuerCertificate.toString('pem') + await issuerTenant1.x509.setTrustedCertificates([selfSignedIssuerCertPem]) + + const openIdIssuerTenant1 = await issuerTenant1.modules.openId4VcIssuer.createIssuer({ + dpopSigningAlgValuesSupported: [JwaSignatureAlgorithm.ES256], + credentialConfigurationsSupported: { + universityDegree: universityDegreeCredentialConfigurationSupportedMdoc, + }, + }) + const issuer1Record = await issuerTenant1.modules.openId4VcIssuer.getIssuerByIssuerId(openIdIssuerTenant1.issuerId) + expect(issuer1Record.dpopSigningAlgValuesSupported).toEqual(['ES256']) + + expect(issuer1Record.credentialsSupported).toEqual([ + { + id: 'universityDegree', + format: 'mso_mdoc', + cryptographic_binding_methods_supported: ['did:key'], + doctype: universityDegreeCredentialConfigurationSupportedMdoc.doctype, + scope: 'UniversityDegreeCredential', + }, + ]) + expect(issuer1Record.credentialConfigurationsSupported).toEqual({ + universityDegree: { + format: 'mso_mdoc', + cryptographic_binding_methods_supported: ['did:key'], + proof_types_supported: { + jwt: { + proof_signing_alg_values_supported: ['ES256'], + }, + }, + doctype: universityDegreeCredentialConfigurationSupportedMdoc.doctype, + scope: universityDegreeCredentialConfigurationSupportedMdoc.scope, + }, + }) + + const { issuanceSession: issuanceSession1, credentialOffer: credentialOffer1 } = + await issuerTenant1.modules.openId4VcIssuer.createCredentialOffer({ + issuerId: openIdIssuerTenant1.issuerId, + offeredCredentials: ['universityDegree'], + preAuthorizedCodeFlowConfig: {}, // { txCode: { input_mode: 'numeric', length: 4 } }, // TODO: disable due to sphereon limitations + version: 'v1.draft13', + }) + + await issuerTenant1.endSession() + + await waitForCredentialIssuanceSessionRecordSubject(issuer.replaySubject, { + state: OpenId4VcIssuanceSessionState.OfferCreated, + issuanceSessionId: issuanceSession1.id, + contextCorrelationId: issuer1.tenantId, + }) + + const holderTenant1 = await holder.agent.modules.tenants.getTenantAgent({ tenantId: holder1.tenantId }) + await holderTenant1.x509.setTrustedCertificates([selfSignedIssuerCertPem]) + + const resolvedCredentialOffer1 = await holderTenant1.modules.openId4VcHolder.resolveCredentialOffer( + credentialOffer1 + ) + + expect(resolvedCredentialOffer1.metadata.credentialIssuerMetadata?.dpop_signing_alg_values_supported).toEqual([ + 'ES256', + ]) + expect(resolvedCredentialOffer1.offeredCredentials).toEqual([ + { + id: 'universityDegree', + doctype: 'UniversityDegreeCredential', + cryptographic_binding_methods_supported: ['did:key'], + format: 'mso_mdoc', + scope: universityDegreeCredentialConfigurationSupportedMdoc.scope, + }, + ]) + + expect(resolvedCredentialOffer1.credentialOfferRequestWithBaseUrl.credential_offer.credential_issuer).toEqual( + `${issuanceBaseUrl}/${openIdIssuerTenant1.issuerId}` + ) + expect(resolvedCredentialOffer1.metadata.credentialIssuerMetadata?.token_endpoint).toEqual( + `${issuanceBaseUrl}/${openIdIssuerTenant1.issuerId}/token` + ) + expect(resolvedCredentialOffer1.metadata.credentialIssuerMetadata?.credential_endpoint).toEqual( + `${issuanceBaseUrl}/${openIdIssuerTenant1.issuerId}/credential` + ) + + // Bind to JWK + const tokenResponseTenant1 = await holderTenant1.modules.openId4VcHolder.requestToken({ + resolvedCredentialOffer: resolvedCredentialOffer1, + }) + + expect(tokenResponseTenant1.accessToken).toBeDefined() + expect(tokenResponseTenant1.dpop?.jwk).toBeInstanceOf(Jwk) + const { payload } = Jwt.fromSerializedJwt(tokenResponseTenant1.accessToken) + expect(payload.additionalClaims.token_type).toEqual('DPoP') + + const credentialsTenant1 = await holderTenant1.modules.openId4VcHolder.requestCredentials({ + resolvedCredentialOffer: resolvedCredentialOffer1, + ...tokenResponseTenant1, + credentialBindingResolver, + }) + + // Wait for all events + await waitForCredentialIssuanceSessionRecordSubject(issuer.replaySubject, { + state: OpenId4VcIssuanceSessionState.AccessTokenRequested, + issuanceSessionId: issuanceSession1.id, + contextCorrelationId: issuer1.tenantId, + }) + await waitForCredentialIssuanceSessionRecordSubject(issuer.replaySubject, { + state: OpenId4VcIssuanceSessionState.AccessTokenCreated, + issuanceSessionId: issuanceSession1.id, + contextCorrelationId: issuer1.tenantId, + }) + await waitForCredentialIssuanceSessionRecordSubject(issuer.replaySubject, { + state: OpenId4VcIssuanceSessionState.CredentialRequestReceived, + issuanceSessionId: issuanceSession1.id, + contextCorrelationId: issuer1.tenantId, + }) + await waitForCredentialIssuanceSessionRecordSubject(issuer.replaySubject, { + state: OpenId4VcIssuanceSessionState.Completed, + issuanceSessionId: issuanceSession1.id, + contextCorrelationId: issuer1.tenantId, + }) + + expect(credentialsTenant1).toHaveLength(1) + const mdocBase64Url = (credentialsTenant1[0].credential as Mdoc).base64Url + const mdoc = holderTenant1.mdoc.fromBase64Url(mdocBase64Url) + expect(mdoc.docType).toEqual('UniversityDegreeCredential') + + await holderTenant1.endSession() + }) }) diff --git a/packages/openid4vc/tests/utilsVci.ts b/packages/openid4vc/tests/utilsVci.ts index f4688ba4ef..be87dd96ad 100644 --- a/packages/openid4vc/tests/utilsVci.ts +++ b/packages/openid4vc/tests/utilsVci.ts @@ -38,6 +38,16 @@ export const universityDegreeCredentialConfigurationSupported = { cryptographic_binding_methods_supported: ['did:key'], } satisfies OpenId4VciCredentialConfigurationSupported +export const universityDegreeCredentialConfigurationSupportedMdoc = { + format: OpenId4VciCredentialFormatProfile.MsoMdoc, + scope: 'UniversityDegreeCredential', + doctype: 'UniversityDegreeCredential', + proof_types_supported: { + jwt: { proof_signing_alg_values_supported: ['ES256'] }, + }, + cryptographic_binding_methods_supported: ['did:key'], +} satisfies OpenId4VciCredentialConfigurationSupported + export const universityDegreeCredentialSdJwt2 = { id: 'https://openid4vc-issuer.com/credentials/UniversityDegreeCredentialSdJwt2', format: OpenId4VciCredentialFormatProfile.SdJwtVc, diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 9eead0b120..ebaa8e8b3b 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -12429,7 +12429,7 @@ snapshots: debug: 4.3.6 enhanced-resolve: 5.17.1 eslint: 8.57.0 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) eslint-plugin-import: 2.29.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) fast-glob: 3.3.2 get-tsconfig: 4.7.6 @@ -12441,7 +12441,7 @@ snapshots: - eslint-import-resolver-webpack - supports-color - eslint-module-utils@2.8.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0): + eslint-module-utils@2.8.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0): dependencies: debug: 3.2.7 optionalDependencies: @@ -12462,7 +12462,7 @@ snapshots: doctrine: 2.1.0 eslint: 8.57.0 eslint-import-resolver-node: 0.3.9 - eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1)(eslint@8.57.0) + eslint-module-utils: 2.8.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-import-resolver-node@0.3.9)(eslint-import-resolver-typescript@3.6.1(@typescript-eslint/parser@7.18.0(eslint@8.57.0)(typescript@5.5.4))(eslint-plugin-import@2.29.1)(eslint@8.57.0))(eslint@8.57.0) hasown: 2.0.2 is-core-module: 2.15.0 is-glob: 4.0.3