From 798d673fd90441986956ca98b2e04b6f4383395e Mon Sep 17 00:00:00 2001 From: Berend Sliedrecht Date: Tue, 27 May 2025 14:03:00 +0200 Subject: [PATCH 1/6] chore: custom verification callback for the authorization request on the holder side for verification Signed-off-by: Berend Sliedrecht --- packages/openid4vc/package.json | 8 +- .../OpenId4vpHolderService.ts | 10 +++ .../OpenId4vpHolderServiceOptions.ts | 12 ++- .../openid4vc/tests/openid4vc.e2e.test.ts | 87 +++++++++++++++++++ pnpm-lock.yaml | 50 +++++------ 5 files changed, 136 insertions(+), 31 deletions(-) diff --git a/packages/openid4vc/package.json b/packages/openid4vc/package.json index 64d1d15022..c97f505f8d 100644 --- a/packages/openid4vc/package.json +++ b/packages/openid4vc/package.json @@ -28,10 +28,10 @@ "class-transformer": "^0.5.1", "rxjs": "^7.8.0", "zod": "^3.24.2", - "@openid4vc/openid4vci": "0.3.0-alpha-20250511195407", - "@openid4vc/oauth2": "0.3.0-alpha-20250511195407", - "@openid4vc/openid4vp": "0.3.0-alpha-20250511195407", - "@openid4vc/utils": "0.3.0-alpha-20250511195407" + "@openid4vc/openid4vci": "0.3.0-alpha-20250527111829", + "@openid4vc/oauth2": "0.3.0-alpha-20250527111829", + "@openid4vc/openid4vp": "0.3.0-alpha-20250527111829", + "@openid4vc/utils": "0.3.0-alpha-20250527111829" }, "devDependencies": { "@credo-ts/tenants": "workspace:*", diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts index 3eb2e406e3..6a4245e48d 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts @@ -141,6 +141,16 @@ export class OpenId4VpHolderService { const dcqlResult = dcql?.query ? await this.handleDcqlRequest(agentContext, dcql.query, transactionData) : undefined + if (options?.verifyAuthorizationRequestCallback) { + const result = await options.verifyAuthorizationRequestCallback({ + authorizationRequest: verifiedAuthorizationRequest.authorizationRequestPayload, + }) + + if (!result) { + throw new CredoError('verificationAuthorizationCallback returned false. User-provided validation failed.') + } + } + agentContext.config.logger.debug('verified Authorization Request') agentContext.config.logger.debug(`request '${authorizationRequest}'`) diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts index 6738ea77d2..417b0dcdbe 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts @@ -1,4 +1,5 @@ import type { + CanBePromise, DcqlCredentialsForRequest, DcqlQueryResult, DifPexCredentialsForRequest, @@ -6,15 +7,22 @@ import type { DifPresentationExchangeDefinition, EncodedX509Certificate, } from '@credo-ts/core' -import { ResolvedOpenid4vpAuthorizationRequest } from '@openid4vc/openid4vp' -import type { OpenId4VpAuthorizationRequestPayload } from '../shared' +import { Openid4vpAuthorizationRequestDcApi, ResolvedOpenid4vpAuthorizationRequest } from '@openid4vc/openid4vp' +import type { OpenId4VpAuthorizationRequestPayload, Openid4vpAuthorizationRequest } from '../shared' // TODO: export from oid4vp export type ParsedTransactionDataEntry = NonNullable[number] +export type VerifyAuthorizationRequestOptions = { + authorizationRequest: Openid4vpAuthorizationRequest | Openid4vpAuthorizationRequestDcApi +} + +export type VerifyAuthorizationRequestCallback = (options: VerifyAuthorizationRequestOptions) => CanBePromise + export interface ResolveOpenId4VpAuthorizationRequestOptions { trustedCertificates?: EncodedX509Certificate[] origin?: string + verifyAuthorizationRequestCallback?: VerifyAuthorizationRequestCallback } type VerifiedJarRequest = NonNullable diff --git a/packages/openid4vc/tests/openid4vc.e2e.test.ts b/packages/openid4vc/tests/openid4vc.e2e.test.ts index 0486a1600c..893a8f2770 100644 --- a/packages/openid4vc/tests/openid4vc.e2e.test.ts +++ b/packages/openid4vc/tests/openid4vc.e2e.test.ts @@ -353,6 +353,93 @@ describe('OpenId4Vc', () => { clearNock() }) + it('e2e flow with tenants, holder verification callback for authorization request fails', async () => { + const holderTenant = await holder.agent.modules.tenants.getTenantAgent({ tenantId: holder1.tenantId }) + const verifierTenant1 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier1.tenantId }) + const verifierTenant2 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier2.tenantId }) + + const openIdVerifierTenant1 = await verifierTenant1.modules.openId4VcVerifier.createVerifier() + const openIdVerifierTenant2 = await verifierTenant2.modules.openId4VcVerifier.createVerifier() + + const signedCredential1 = await issuer.agent.w3cCredentials.signCredential({ + format: ClaimFormat.JwtVc, + credential: new W3cCredential({ + type: ['VerifiableCredential', 'OpenBadgeCredential'], + issuer: new W3cIssuer({ id: issuer.did }), + credentialSubject: new W3cCredentialSubject({ id: holder1.did }), + issuanceDate: w3cDate(Date.now()), + }), + alg: Kms.KnownJwaSignatureAlgorithms.EdDSA, + verificationMethod: issuer.verificationMethod.id, + }) + + const signedCredential2 = await issuer.agent.w3cCredentials.signCredential({ + format: ClaimFormat.JwtVc, + credential: new W3cCredential({ + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + issuer: new W3cIssuer({ id: issuer.did }), + credentialSubject: new W3cCredentialSubject({ id: holder1.did }), + issuanceDate: w3cDate(Date.now()), + }), + alg: Kms.KnownJwaSignatureAlgorithms.EdDSA, + verificationMethod: issuer.verificationMethod.id, + }) + + await holderTenant.w3cCredentials.storeCredential({ credential: signedCredential1 }) + await holderTenant.w3cCredentials.storeCredential({ credential: signedCredential2 }) + + const { authorizationRequest: authorizationRequestUri1, verificationSession: verificationSession1 } = + await verifierTenant1.modules.openId4VcVerifier.createAuthorizationRequest({ + verifierId: openIdVerifierTenant1.verifierId, + requestSigner: { + method: 'did', + didUrl: verifier1.verificationMethod.id, + }, + presentationExchange: { + definition: openBadgePresentationDefinition, + }, + }) + + expect(authorizationRequestUri1).toEqual( + `openid4vp://?client_id=${encodeURIComponent(verifier1.did)}&request_uri=${encodeURIComponent( + verificationSession1.authorizationRequestUri as string + )}` + ) + + const { authorizationRequest: authorizationRequestUri2, verificationSession: verificationSession2 } = + await verifierTenant2.modules.openId4VcVerifier.createAuthorizationRequest({ + requestSigner: { + method: 'did', + didUrl: verifier2.verificationMethod.id, + }, + presentationExchange: { + definition: universityDegreePresentationDefinition, + }, + verifierId: openIdVerifierTenant2.verifierId, + }) + + expect(authorizationRequestUri2).toEqual( + `openid4vp://?client_id=${encodeURIComponent(verifier2.did)}&request_uri=${encodeURIComponent( + verificationSession2.authorizationRequestUri as string + )}` + ) + + await verifierTenant1.endSession() + await verifierTenant2.endSession() + + await expect( + holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest(authorizationRequestUri1, { + verifyAuthorizationRequestCallback: () => false, + }) + ).rejects.toThrow() + + await expect( + holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest(authorizationRequestUri1, { + verifyAuthorizationRequestCallback: () => true, + }) + ).resolves.toBeDefined() + }) + it('e2e flow with tenants, verifier endpoints verifying a jwt-vc', async () => { const holderTenant = await holder.agent.modules.tenants.getTenantAgent({ tenantId: holder1.tenantId }) const verifierTenant1 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier1.tenantId }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5d5d4e3de0..4e7cd4b9ab 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -703,17 +703,17 @@ importers: specifier: workspace:* version: link:../core '@openid4vc/oauth2': - specifier: 0.3.0-alpha-20250511195407 - version: 0.3.0-alpha-20250511195407 + specifier: 0.3.0-alpha-20250527111829 + version: 0.3.0-alpha-20250527111829 '@openid4vc/openid4vci': - specifier: 0.3.0-alpha-20250511195407 - version: 0.3.0-alpha-20250511195407 + specifier: 0.3.0-alpha-20250527111829 + version: 0.3.0-alpha-20250527111829 '@openid4vc/openid4vp': - specifier: 0.3.0-alpha-20250511195407 - version: 0.3.0-alpha-20250511195407 + specifier: 0.3.0-alpha-20250527111829 + version: 0.3.0-alpha-20250527111829 '@openid4vc/utils': - specifier: 0.3.0-alpha-20250511195407 - version: 0.3.0-alpha-20250511195407 + specifier: 0.3.0-alpha-20250527111829 + version: 0.3.0-alpha-20250527111829 class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -2469,17 +2469,17 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@openid4vc/oauth2@0.3.0-alpha-20250511195407': - resolution: {integrity: sha512-H4SYmrszAm/qk+P35jk1vEVIIRTkhLTZOzTO0pTKBzDMordgAPyD06EDxw40mVEY3vY1IHICloUq8AzNtBPKOA==} + '@openid4vc/oauth2@0.3.0-alpha-20250527111829': + resolution: {integrity: sha512-AcBSWPya3Tcgih5YgVoYUwkbrWPmm95gO3R1di0Xzz6mrVnbaMMT9HN0OBE3mHtDQ6t+tkPToZ057TF13cdWxg==} - '@openid4vc/openid4vci@0.3.0-alpha-20250511195407': - resolution: {integrity: sha512-OMQbQNym2hDWrfldZgYdzaVKwE83WE7aExQgL299WBd8nr9fvUaoG74+GtTHX/bgDXcLR7XwoUmcLsWByqSXpA==} + '@openid4vc/openid4vci@0.3.0-alpha-20250527111829': + resolution: {integrity: sha512-GiR2ueOhm0hxEmuuCoT779o9SwrwpgR9ZkAdqOPH/A1dlippvcLodWNB0tWIiwU9MOyzXjsptPJy3WAVhfw4+A==} - '@openid4vc/openid4vp@0.3.0-alpha-20250511195407': - resolution: {integrity: sha512-bOTzFCv7gDcuDJ1JoWUPDlqVVjGkGVuQRWlnpl792bbsflkKQ7OCPIFuOH6oqxutPwE0ehV0nt8Pc0lUTwutTw==} + '@openid4vc/openid4vp@0.3.0-alpha-20250527111829': + resolution: {integrity: sha512-gtbANPNVp3UhdNkwzYEi7pkAyN6Zl8pksq9eFF0JImQsOLc1bFn/aaPq47tyw0g+ok+tZ93X22KNdqLBeBtNog==} - '@openid4vc/utils@0.3.0-alpha-20250511195407': - resolution: {integrity: sha512-S9c7GVEoohMbWY0CJsIchqcMy6le2hGUAEqAIF5R9PaaADviXOFoyaitXIa8rMIqNuPMk66Fs2IW1PvwnbI7EQ==} + '@openid4vc/utils@0.3.0-alpha-20250527111829': + resolution: {integrity: sha512-rYu2lnz9wGbFh3xZa4o4p8n6J+E6GxP9v/3jP5DUJAKTuU9U/UM4lHBlI9fn5QW4jsp58jlf2E+Jxi7EBwbdWw==} '@openwallet-foundation/askar-nodejs@0.3.1': resolution: {integrity: sha512-m3L8KEPC+qgA3MAFssMtjSqJiAQtrawZEWPmW6eiB7OPjZvkKjodMhx/cuUV5YTl4eQlSix2EY4vXMzk4vt+cQ==} @@ -10022,24 +10022,24 @@ snapshots: '@open-draft/until@2.1.0': {} - '@openid4vc/oauth2@0.3.0-alpha-20250511195407': + '@openid4vc/oauth2@0.3.0-alpha-20250527111829': dependencies: - '@openid4vc/utils': 0.3.0-alpha-20250511195407 + '@openid4vc/utils': 0.3.0-alpha-20250527111829 zod: 3.24.2 - '@openid4vc/openid4vci@0.3.0-alpha-20250511195407': + '@openid4vc/openid4vci@0.3.0-alpha-20250527111829': dependencies: - '@openid4vc/oauth2': 0.3.0-alpha-20250511195407 - '@openid4vc/utils': 0.3.0-alpha-20250511195407 + '@openid4vc/oauth2': 0.3.0-alpha-20250527111829 + '@openid4vc/utils': 0.3.0-alpha-20250527111829 zod: 3.24.2 - '@openid4vc/openid4vp@0.3.0-alpha-20250511195407': + '@openid4vc/openid4vp@0.3.0-alpha-20250527111829': dependencies: - '@openid4vc/oauth2': 0.3.0-alpha-20250511195407 - '@openid4vc/utils': 0.3.0-alpha-20250511195407 + '@openid4vc/oauth2': 0.3.0-alpha-20250527111829 + '@openid4vc/utils': 0.3.0-alpha-20250527111829 zod: 3.24.2 - '@openid4vc/utils@0.3.0-alpha-20250511195407': + '@openid4vc/utils@0.3.0-alpha-20250527111829': dependencies: buffer: 6.0.3 zod: 3.24.2 From 8eff971e367d2aced7772d034f4eea4ed468d7d7 Mon Sep 17 00:00:00 2001 From: Berend Sliedrecht Date: Tue, 27 May 2025 14:52:01 +0200 Subject: [PATCH 2/6] docs(changeset): Added feature to add a verifyAuthorizationCallback as the holder to do additional validation on the authorization request when resolving it Signed-off-by: Berend Sliedrecht --- .changeset/chilly-bears-jump.md | 5 +++++ 1 file changed, 5 insertions(+) create mode 100644 .changeset/chilly-bears-jump.md diff --git a/.changeset/chilly-bears-jump.md b/.changeset/chilly-bears-jump.md new file mode 100644 index 0000000000..2b268fc416 --- /dev/null +++ b/.changeset/chilly-bears-jump.md @@ -0,0 +1,5 @@ +--- +"@credo-ts/openid4vc": patch +--- + +Added feature to add a verifyAuthorizationCallback as the holder to do additional validation on the authorization request when resolving it From ae03ac5781486d102a1e88d82608f042238d7d19 Mon Sep 17 00:00:00 2001 From: Berend Sliedrecht Date: Wed, 28 May 2025 12:51:49 +0200 Subject: [PATCH 3/6] tests: added better callback for verifier_attestation verification based on the registration_certificate Signed-off-by: Berend Sliedrecht --- packages/openid4vc/package.json | 8 +- .../OpenId4vpHolderService.ts | 22 ++- .../OpenId4vpHolderServiceOptions.ts | 6 +- .../openid4vc/tests/openid4vc.e2e.test.ts | 160 +++++++++++++++++- pnpm-lock.yaml | 50 +++--- 5 files changed, 207 insertions(+), 39 deletions(-) diff --git a/packages/openid4vc/package.json b/packages/openid4vc/package.json index c97f505f8d..020f507ac7 100644 --- a/packages/openid4vc/package.json +++ b/packages/openid4vc/package.json @@ -28,10 +28,10 @@ "class-transformer": "^0.5.1", "rxjs": "^7.8.0", "zod": "^3.24.2", - "@openid4vc/openid4vci": "0.3.0-alpha-20250527111829", - "@openid4vc/oauth2": "0.3.0-alpha-20250527111829", - "@openid4vc/openid4vp": "0.3.0-alpha-20250527111829", - "@openid4vc/utils": "0.3.0-alpha-20250527111829" + "@openid4vc/openid4vci": "0.3.0-alpha-20250528092219", + "@openid4vc/oauth2": "0.3.0-alpha-20250528092219", + "@openid4vc/openid4vp": "0.3.0-alpha-20250528092219", + "@openid4vc/utils": "0.3.0-alpha-20250528092219" }, "devDependencies": { "@credo-ts/tenants": "workspace:*", diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts index 6a4245e48d..099e4a0130 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts @@ -23,11 +23,13 @@ import { DifPresentationExchangeService, DifPresentationExchangeSubmissionLocation, Hasher, + JwsService, Kms, TypedArrayEncoder, injectable, } from '@credo-ts/core' import { + Openid4vpAuthorizationRequest, Openid4vpAuthorizationResponse, Openid4vpClient, VpToken, @@ -142,12 +144,20 @@ export class OpenId4VpHolderService { const dcqlResult = dcql?.query ? await this.handleDcqlRequest(agentContext, dcql.query, transactionData) : undefined if (options?.verifyAuthorizationRequestCallback) { - const result = await options.verifyAuthorizationRequestCallback({ - authorizationRequest: verifiedAuthorizationRequest.authorizationRequestPayload, - }) - - if (!result) { - throw new CredoError('verificationAuthorizationCallback returned false. User-provided validation failed.') + try { + await options.verifyAuthorizationRequestCallback({ + authorizationRequest: + verifiedAuthorizationRequest.authorizationRequestPayload as Openid4vpAuthorizationRequest, + jwsService: agentContext.resolve(JwsService), + client, + }) + } catch (e) { + throw new CredoError( + `verificationAuthorizationCallback returned false. User-provided validation failed. cause: ${e}`, + { + cause: e, + } + ) } } diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts index 417b0dcdbe..0e22ecac84 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts @@ -6,6 +6,7 @@ import type { DifPexInputDescriptorToCredentials, DifPresentationExchangeDefinition, EncodedX509Certificate, + JwsService, } from '@credo-ts/core' import { Openid4vpAuthorizationRequestDcApi, ResolvedOpenid4vpAuthorizationRequest } from '@openid4vc/openid4vp' import type { OpenId4VpAuthorizationRequestPayload, Openid4vpAuthorizationRequest } from '../shared' @@ -15,9 +16,12 @@ export type ParsedTransactionDataEntry = NonNullable CanBePromise +export type VerifyAuthorizationRequestCallback = (options: VerifyAuthorizationRequestOptions) => CanBePromise export interface ResolveOpenId4VpAuthorizationRequestOptions { trustedCertificates?: EncodedX509Certificate[] diff --git a/packages/openid4vc/tests/openid4vc.e2e.test.ts b/packages/openid4vc/tests/openid4vc.e2e.test.ts index 893a8f2770..a7389b36bc 100644 --- a/packages/openid4vc/tests/openid4vc.e2e.test.ts +++ b/packages/openid4vc/tests/openid4vc.e2e.test.ts @@ -43,6 +43,7 @@ import { } from '../src' import { getOid4vcCallbacks } from '../src/shared/callbacks' +import { z } from 'zod' import { InMemoryWalletModule } from '../../../tests/InMemoryWalletModule' import { setupNockToExpress } from '../../../tests/nockToExpress' import { @@ -154,7 +155,20 @@ describe('OpenId4Vc', () => { openId4VcHolder: new OpenId4VcHolderModule(), inMemory: new InMemoryWalletModule(), tenants: new TenantsModule(), - x509: new X509Module(), + x509: new X509Module({ + trustedCertificates: [ + `-----BEGIN CERTIFICATE----- +MIIBdTCCARugAwIBAgIUHsSmbGuWAVZVXjqoidqAVClGx4YwCgYIKoZIzj0EAwIw +GzEZMBcGA1UEAwwQR2VybWFuIFJlZ2lzdHJhcjAeFw0yNTAzMzAxOTU4NTFaFw0y +NjAzMzAxOTU4NTFaMBsxGTAXBgNVBAMMEEdlcm1hbiBSZWdpc3RyYXIwWTATBgcq +hkjOPQIBBggqhkjOPQMBBwNCAASQWCESFd0Ywm9sK87XxqxDP4wOAadEKgcZFVX7 +npe3ALFkbjsXYZJsTGhVp0+B5ZtUao2NsyzJCKznPwTz2wJcoz0wOzAaBgNVHREE +EzARgg9mdW5rZS13YWxsZXQuZGUwHQYDVR0OBBYEFMxnKLkGifbTKrxbGXcFXK6R +FQd3MAoGCCqGSM49BAMCA0gAMEUCIQD4RiLJeuVDrEHSvkPiPfBvMxAXRC6PuExo +pUGCFdfNLQIgHGSa5u5ZqUtCrnMiaEageO71rjzBlov0YUH4+6ELioY= +-----END CERTIFICATE-----`, + ], + }), }, '96213c3d7fc8d4d6754c7a0fd969598e', global.fetch @@ -429,17 +443,157 @@ describe('OpenId4Vc', () => { await expect( holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest(authorizationRequestUri1, { - verifyAuthorizationRequestCallback: () => false, + verifyAuthorizationRequestCallback: () => { + throw Error('testing error') + }, }) ).rejects.toThrow() await expect( holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest(authorizationRequestUri1, { - verifyAuthorizationRequestCallback: () => true, + verifyAuthorizationRequestCallback: () => {}, }) ).resolves.toBeDefined() }) + it('e2e flow with tenants, holder verification callback for authorization request succeeds', async () => { + const holderTenant = await holder.agent.modules.tenants.getTenantAgent({ tenantId: holder1.tenantId }) + + await expect( + holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest( + 'openid4vp://?client_id=x509_san_dns%3Afunke-wallet.de&request_uri=https%3A%2F%2Ffunke-wallet.de%2Foid4vp%2Fdraft-24%2Fvalid-request%2Fdcql', + { + verifyAuthorizationRequestCallback: async ({ authorizationRequest, jwsService, client }) => { + if (!authorizationRequest.verifier_attestations) return + for (const va of authorizationRequest.verifier_attestations) { + // Here we verify it as a registration certificate according to + // https://bmi.usercontent.opencode.de/eudi-wallet/eidas-2.0-architekturkonzept/flows/Wallet-Relying-Party-Authentication/#registration-certificate + if (va.format === 'jwt') { + if (typeof va.data !== 'string') { + throw new Error('Only inline JWTs are supported') + } + + const { isValid } = await jwsService.verifyJws(holder.agent.context, { jws: va.data }) + const jwt = Jwt.fromSerializedJwt(va.data) + + if (!isValid) { + throw new Error('Invalid signature on JWT provided in the verifier_attestations') + } + + if (jwt.header.typ !== 'rc-rp+jwt') { + throw new Error(`only 'rc-rp+jwt' is supported as header typ. Request included: ${jwt.header.typ}`) + } + + const registrationCertificateHeaderSchema = z + .object({ + typ: z.literal('rc-rp+jwt'), + alg: z.string(), + // sprin-d did not define this + x5u: z.string().url().optional(), + // sprin-d did not define this + 'x5t#s256': z.string().optional(), + }) + .passthrough() + + // TODO: does not support intermediaries + const registrationCertificatePayloadSchema = z + .object({ + credentials: z.array( + z.object({ + id: z.string().optional(), + format: z.string(), + multiple: z.boolean().default(false), + meta: z + .object({ + vct_values: z.array(z.string()).optional(), + doctype_value: z.string().optional(), + }) + .optional(), + trusted_authorities: z + .array(z.object({ type: z.string(), values: z.array(z.string()) })) + .nonempty() + .optional(), + require_cryptographic_holder_binding: z.boolean().default(true), + claims: z + .array( + z.object({ + id: z.string().optional(), + path: z.array(z.string()).nonempty().nonempty(), + values: z.array(z.number().or(z.boolean())).optional(), + }) + ) + .nonempty() + .optional(), + claim_sets: z.array(z.array(z.string())).nonempty().optional(), + }) + ), + contact: z.object({ + website: z.string().url(), + 'e-mail': z.string().email(), + phone: z.string(), + }), + sub: z.string(), + // Should be service + services: z.array(z.object({ lang: z.string(), name: z.string() })), + public_body: z.boolean().default(false), + entitlements: z.array(z.any()), + provided_attestations: z + .array( + z.object({ + format: z.string(), + meta: z.any(), + }) + ) + .optional(), + privacy_policy: z.string().url(), + iat: z.number().optional(), + exp: z.number().optional(), + purpose: z + .array( + z.object({ + locale: z.string().optional(), + lang: z.string().optional(), + name: z.string(), + }) + ) + .optional(), + status: z.any(), + }) + .passthrough() + + const _parsedHeader = registrationCertificateHeaderSchema.parse(jwt.header) + const parsedPayload = registrationCertificatePayloadSchema.parse(jwt.payload.toJson()) + + if (client.scheme !== 'x509_san_dns' && client.scheme !== 'x509_san_uri') { + throw new Error(`Unsupported client scheme ${client.scheme}`) + } + + const [rpCertEncoded] = client.x5c + const rpCert = X509Certificate.fromEncodedCertificate(rpCertEncoded) + + if (rpCert.subject !== parsedPayload.sub) { + throw new Error( + `Subject in the certificate of the auth request: '${rpCert.subject}' is not equal to the subject of the registration certificate: '${parsedPayload.sub}'` + ) + } + + if (parsedPayload.iat && new Date().getTime() / 1000 <= parsedPayload.iat) { + throw new Error('Issued at timestamp of the registration certificate is in the future') + } + + // TODO: check the status of the registration certificate + + // TODO: validate if they can request the credentials!!!! + } else { + throw new Error(`only format of 'jwt' is supported`) + } + } + }, + } + ) + ).resolves.toBeDefined() + }) + it('e2e flow with tenants, verifier endpoints verifying a jwt-vc', async () => { const holderTenant = await holder.agent.modules.tenants.getTenantAgent({ tenantId: holder1.tenantId }) const verifierTenant1 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier1.tenantId }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 4e7cd4b9ab..4826c5740d 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -703,17 +703,17 @@ importers: specifier: workspace:* version: link:../core '@openid4vc/oauth2': - specifier: 0.3.0-alpha-20250527111829 - version: 0.3.0-alpha-20250527111829 + specifier: 0.3.0-alpha-20250528092219 + version: 0.3.0-alpha-20250528092219 '@openid4vc/openid4vci': - specifier: 0.3.0-alpha-20250527111829 - version: 0.3.0-alpha-20250527111829 + specifier: 0.3.0-alpha-20250528092219 + version: 0.3.0-alpha-20250528092219 '@openid4vc/openid4vp': - specifier: 0.3.0-alpha-20250527111829 - version: 0.3.0-alpha-20250527111829 + specifier: 0.3.0-alpha-20250528092219 + version: 0.3.0-alpha-20250528092219 '@openid4vc/utils': - specifier: 0.3.0-alpha-20250527111829 - version: 0.3.0-alpha-20250527111829 + specifier: 0.3.0-alpha-20250528092219 + version: 0.3.0-alpha-20250528092219 class-transformer: specifier: ^0.5.1 version: 0.5.1 @@ -2469,17 +2469,17 @@ packages: '@open-draft/until@2.1.0': resolution: {integrity: sha512-U69T3ItWHvLwGg5eJ0n3I62nWuE6ilHlmz7zM0npLBRvPRd7e6NYmg54vvRtP5mZG7kZqZCFVdsTWo7BPtBujg==} - '@openid4vc/oauth2@0.3.0-alpha-20250527111829': - resolution: {integrity: sha512-AcBSWPya3Tcgih5YgVoYUwkbrWPmm95gO3R1di0Xzz6mrVnbaMMT9HN0OBE3mHtDQ6t+tkPToZ057TF13cdWxg==} + '@openid4vc/oauth2@0.3.0-alpha-20250528092219': + resolution: {integrity: sha512-3tbamT7r7ZJ6UxbdO5f4VEAxYUhWl9GwDC+edxAM1Wg5CDn6DPvkweQSlj01CwFcgo9ek+WztUVsfkaigfpOkQ==} - '@openid4vc/openid4vci@0.3.0-alpha-20250527111829': - resolution: {integrity: sha512-GiR2ueOhm0hxEmuuCoT779o9SwrwpgR9ZkAdqOPH/A1dlippvcLodWNB0tWIiwU9MOyzXjsptPJy3WAVhfw4+A==} + '@openid4vc/openid4vci@0.3.0-alpha-20250528092219': + resolution: {integrity: sha512-/85Sj1Z15V6EzKz2aLoGwrQv6uz3twEYJHXfiTIMqqApCaHAYgOjQUpgY1yCcMKH+VwFg7yTQmSBu3XiXT4kBQ==} - '@openid4vc/openid4vp@0.3.0-alpha-20250527111829': - resolution: {integrity: sha512-gtbANPNVp3UhdNkwzYEi7pkAyN6Zl8pksq9eFF0JImQsOLc1bFn/aaPq47tyw0g+ok+tZ93X22KNdqLBeBtNog==} + '@openid4vc/openid4vp@0.3.0-alpha-20250528092219': + resolution: {integrity: sha512-FZ4R3egk+FdkzTAvFuoSpLv4BUsFJUeuvZLzKruR+xr1scTb9CDPM+3NcMalht+yxatOeAZLzSAKlQKbOLy4lg==} - '@openid4vc/utils@0.3.0-alpha-20250527111829': - resolution: {integrity: sha512-rYu2lnz9wGbFh3xZa4o4p8n6J+E6GxP9v/3jP5DUJAKTuU9U/UM4lHBlI9fn5QW4jsp58jlf2E+Jxi7EBwbdWw==} + '@openid4vc/utils@0.3.0-alpha-20250528092219': + resolution: {integrity: sha512-qSXCy8oyq3clNqBXIJPxO2ZUnJLJaoW/8xW4QP3nBeHS/i5EeLVOatjVPHgNTbfobdwm+Lz8I36DKyj+LT7dvA==} '@openwallet-foundation/askar-nodejs@0.3.1': resolution: {integrity: sha512-m3L8KEPC+qgA3MAFssMtjSqJiAQtrawZEWPmW6eiB7OPjZvkKjodMhx/cuUV5YTl4eQlSix2EY4vXMzk4vt+cQ==} @@ -10022,24 +10022,24 @@ snapshots: '@open-draft/until@2.1.0': {} - '@openid4vc/oauth2@0.3.0-alpha-20250527111829': + '@openid4vc/oauth2@0.3.0-alpha-20250528092219': dependencies: - '@openid4vc/utils': 0.3.0-alpha-20250527111829 + '@openid4vc/utils': 0.3.0-alpha-20250528092219 zod: 3.24.2 - '@openid4vc/openid4vci@0.3.0-alpha-20250527111829': + '@openid4vc/openid4vci@0.3.0-alpha-20250528092219': dependencies: - '@openid4vc/oauth2': 0.3.0-alpha-20250527111829 - '@openid4vc/utils': 0.3.0-alpha-20250527111829 + '@openid4vc/oauth2': 0.3.0-alpha-20250528092219 + '@openid4vc/utils': 0.3.0-alpha-20250528092219 zod: 3.24.2 - '@openid4vc/openid4vp@0.3.0-alpha-20250527111829': + '@openid4vc/openid4vp@0.3.0-alpha-20250528092219': dependencies: - '@openid4vc/oauth2': 0.3.0-alpha-20250527111829 - '@openid4vc/utils': 0.3.0-alpha-20250527111829 + '@openid4vc/oauth2': 0.3.0-alpha-20250528092219 + '@openid4vc/utils': 0.3.0-alpha-20250528092219 zod: 3.24.2 - '@openid4vc/utils@0.3.0-alpha-20250527111829': + '@openid4vc/utils@0.3.0-alpha-20250528092219': dependencies: buffer: 6.0.3 zod: 3.24.2 From cc916d4f9cc7a9c03b262eaa16a69ffdb53db49a Mon Sep 17 00:00:00 2001 From: Berend Sliedrecht Date: Wed, 28 May 2025 15:35:48 +0200 Subject: [PATCH 4/6] tests: add test for registration certificate for valid and overasking request Signed-off-by: Berend Sliedrecht --- packages/core/src/index.ts | 1 + packages/core/src/utils/deepEquality.ts | 8 + .../openid4vc/tests/openid4vc.e2e.test.ts | 435 ++++++++++++------ 3 files changed, 309 insertions(+), 135 deletions(-) diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 98263a7001..cd92aca3c0 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -58,6 +58,7 @@ export { IsStringOrInstance, asArray, equalsIgnoreOrder, + equalsWithOrder, DateTransformer, } from './utils' export * from './logger' diff --git a/packages/core/src/utils/deepEquality.ts b/packages/core/src/utils/deepEquality.ts index 9ee070c148..acfda383cd 100644 --- a/packages/core/src/utils/deepEquality.ts +++ b/packages/core/src/utils/deepEquality.ts @@ -31,6 +31,14 @@ export function equalsIgnoreOrder(a: Array, b: Array) return a.every((k) => b.includes(k)) } +/** + * @note This will only work for primitive array equality + */ +export function equalsWithOrder(lhs: Array, rhs: Array): boolean { + if (lhs.length !== rhs.length) return false + return lhs.every((k, i) => k === rhs[i]) +} + // We take any here as we have to check some properties, they will be undefined if they do not exist // biome-ignore lint/suspicious/noExplicitAny: function simpleEqual(x: any, y: any) { diff --git a/packages/openid4vc/tests/openid4vc.e2e.test.ts b/packages/openid4vc/tests/openid4vc.e2e.test.ts index a7389b36bc..e69c935e5f 100644 --- a/packages/openid4vc/tests/openid4vc.e2e.test.ts +++ b/packages/openid4vc/tests/openid4vc.e2e.test.ts @@ -1,13 +1,16 @@ -import type { DcqlQuery, DifPresentationExchangeDefinitionV2, Mdoc, MdocDeviceResponse, SdJwtVc } from '@credo-ts/core' -import type { AuthorizationServerMetadata, Jwk } from '@openid4vc/oauth2' -import type { OpenId4VciSignMdocCredentials } from '../src' -import type { OpenId4VciCredentialBindingResolver } from '../src/openid4vc-holder' -import type { AgentType, TenantType } from './utils' - +import type { + AgentContext, + DcqlQuery, + DifPresentationExchangeDefinitionV2, + Mdoc, + MdocDeviceResponse, + SdJwtVc, +} from '@credo-ts/core' import { ClaimFormat, CredoError, DateOnly, + DcqlService, DidsApi, JwsService, Jwt, @@ -21,10 +24,14 @@ import { X509Certificate, X509Module, X509Service, + deepEquality, + equalsIgnoreOrder, + equalsWithOrder, getPublicJwkFromVerificationMethod, parseDid, w3cDate, } from '@credo-ts/core' +import type { AuthorizationServerMetadata, Jwk } from '@openid4vc/oauth2' import { HashAlgorithm, Oauth2AuthorizationServer, @@ -33,7 +40,11 @@ import { } from '@openid4vc/oauth2' import { AuthorizationFlow } from '@openid4vc/openid4vci' import express, { type Express } from 'express' +import { z } from 'zod' +import { InMemoryWalletModule } from '../../../tests/InMemoryWalletModule' +import { setupNockToExpress } from '../../../tests/nockToExpress' import { TenantsModule } from '../../tenants/src' +import type { OpenId4VciSignMdocCredentials } from '../src' import { OpenId4VcHolderModule, OpenId4VcIssuanceSessionState, @@ -41,11 +52,9 @@ import { OpenId4VcVerificationSessionState, OpenId4VcVerifierModule, } from '../src' +import type { OpenId4VciCredentialBindingResolver, VerifyAuthorizationRequestOptions } from '../src/openid4vc-holder' import { getOid4vcCallbacks } from '../src/shared/callbacks' - -import { z } from 'zod' -import { InMemoryWalletModule } from '../../../tests/InMemoryWalletModule' -import { setupNockToExpress } from '../../../tests/nockToExpress' +import type { AgentType, TenantType } from './utils' import { createAgentFromModules, createTenantForAgent, @@ -463,135 +472,23 @@ pUGCFdfNLQIgHGSa5u5ZqUtCrnMiaEageO71rjzBlov0YUH4+6ELioY= holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest( 'openid4vp://?client_id=x509_san_dns%3Afunke-wallet.de&request_uri=https%3A%2F%2Ffunke-wallet.de%2Foid4vp%2Fdraft-24%2Fvalid-request%2Fdcql', { - verifyAuthorizationRequestCallback: async ({ authorizationRequest, jwsService, client }) => { - if (!authorizationRequest.verifier_attestations) return - for (const va of authorizationRequest.verifier_attestations) { - // Here we verify it as a registration certificate according to - // https://bmi.usercontent.opencode.de/eudi-wallet/eidas-2.0-architekturkonzept/flows/Wallet-Relying-Party-Authentication/#registration-certificate - if (va.format === 'jwt') { - if (typeof va.data !== 'string') { - throw new Error('Only inline JWTs are supported') - } - - const { isValid } = await jwsService.verifyJws(holder.agent.context, { jws: va.data }) - const jwt = Jwt.fromSerializedJwt(va.data) - - if (!isValid) { - throw new Error('Invalid signature on JWT provided in the verifier_attestations') - } - - if (jwt.header.typ !== 'rc-rp+jwt') { - throw new Error(`only 'rc-rp+jwt' is supported as header typ. Request included: ${jwt.header.typ}`) - } - - const registrationCertificateHeaderSchema = z - .object({ - typ: z.literal('rc-rp+jwt'), - alg: z.string(), - // sprin-d did not define this - x5u: z.string().url().optional(), - // sprin-d did not define this - 'x5t#s256': z.string().optional(), - }) - .passthrough() - - // TODO: does not support intermediaries - const registrationCertificatePayloadSchema = z - .object({ - credentials: z.array( - z.object({ - id: z.string().optional(), - format: z.string(), - multiple: z.boolean().default(false), - meta: z - .object({ - vct_values: z.array(z.string()).optional(), - doctype_value: z.string().optional(), - }) - .optional(), - trusted_authorities: z - .array(z.object({ type: z.string(), values: z.array(z.string()) })) - .nonempty() - .optional(), - require_cryptographic_holder_binding: z.boolean().default(true), - claims: z - .array( - z.object({ - id: z.string().optional(), - path: z.array(z.string()).nonempty().nonempty(), - values: z.array(z.number().or(z.boolean())).optional(), - }) - ) - .nonempty() - .optional(), - claim_sets: z.array(z.array(z.string())).nonempty().optional(), - }) - ), - contact: z.object({ - website: z.string().url(), - 'e-mail': z.string().email(), - phone: z.string(), - }), - sub: z.string(), - // Should be service - services: z.array(z.object({ lang: z.string(), name: z.string() })), - public_body: z.boolean().default(false), - entitlements: z.array(z.any()), - provided_attestations: z - .array( - z.object({ - format: z.string(), - meta: z.any(), - }) - ) - .optional(), - privacy_policy: z.string().url(), - iat: z.number().optional(), - exp: z.number().optional(), - purpose: z - .array( - z.object({ - locale: z.string().optional(), - lang: z.string().optional(), - name: z.string(), - }) - ) - .optional(), - status: z.any(), - }) - .passthrough() - - const _parsedHeader = registrationCertificateHeaderSchema.parse(jwt.header) - const parsedPayload = registrationCertificatePayloadSchema.parse(jwt.payload.toJson()) - - if (client.scheme !== 'x509_san_dns' && client.scheme !== 'x509_san_uri') { - throw new Error(`Unsupported client scheme ${client.scheme}`) - } - - const [rpCertEncoded] = client.x5c - const rpCert = X509Certificate.fromEncodedCertificate(rpCertEncoded) - - if (rpCert.subject !== parsedPayload.sub) { - throw new Error( - `Subject in the certificate of the auth request: '${rpCert.subject}' is not equal to the subject of the registration certificate: '${parsedPayload.sub}'` - ) - } - - if (parsedPayload.iat && new Date().getTime() / 1000 <= parsedPayload.iat) { - throw new Error('Issued at timestamp of the registration certificate is in the future') - } + verifyAuthorizationRequestCallback: verifyAuthorizationRequestCallback(holder.agent.context), + } + ) + ).resolves.toBeDefined() + }) - // TODO: check the status of the registration certificate + it('e2e flow with tenants, holder verification callback for authorization request fails with overasking', async () => { + const holderTenant = await holder.agent.modules.tenants.getTenantAgent({ tenantId: holder1.tenantId }) - // TODO: validate if they can request the credentials!!!! - } else { - throw new Error(`only format of 'jwt' is supported`) - } - } - }, + await expect( + holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest( + 'openid4vp://?client_id=x509_san_dns%3Afunke-wallet.de&request_uri=https%3A%2F%2Ffunke-wallet.de%2Foid4vp%2Fdraft-24%2Foverask%2Fdcql', + { + verifyAuthorizationRequestCallback: verifyAuthorizationRequestCallback(holder.agent.context), } ) - ).resolves.toBeDefined() + ).rejects.toThrow() }) it('e2e flow with tenants, verifier endpoints verifying a jwt-vc', async () => { @@ -3090,3 +2987,271 @@ pUGCFdfNLQIgHGSa5u5ZqUtCrnMiaEageO71rjzBlov0YUH4+6ELioY= }) }) }) + +// TODO: might move this to the dcql module and expose it as a util function +function isDcqlRequestValidSubsetOfOtherDcqlRequest( + agentContext: AgentContext, + arq: DcqlQuery, + rcq: DcqlQuery +): boolean { + const dcqlService = agentContext.resolve(DcqlService) + dcqlService.validateDcqlQuery(arq) + // TODO: validate required the `id` property, which is not in the rcq + // const rcqQuery = dcqlService.validateDcqlQuery(arq) + + if (rcq.credential_sets) { + agentContext.config.logger.warn( + 'credential_sets are not allowed on the dcql query for the registration certificate' + ) + return false + } + + if (rcq.credentials.some((c) => c.id)) { + agentContext.config.logger.warn( + 'credentials[n].id is not allowed on the dcql query for the registration certificate' + ) + return false + } + + // Short-circuit for exact match + if (deepEquality(arq.credentials, rcq.credentials)) return true + + // only sd-jwt and mdoc are supported + if (arq.credentials.some((c) => c.format !== 'mso_mdoc' && c.format !== 'vc+sd-jwt' && c.format !== 'dc+sd-jwt')) { + return false + } + + credentialQueryLoop: for (const credentialQuery of arq.credentials) { + const matchingRcqCredentialQueriesBasedOnFormat = rcq.credentials.filter((c) => c.format === credentialQuery.format) + + if (matchingRcqCredentialQueriesBasedOnFormat.length === 0) return false + + switch (credentialQuery.format) { + case 'mso_mdoc': { + const doctypeValue = credentialQuery.meta?.doctype_value + if (!doctypeValue) return false + if (typeof credentialQuery.meta?.doctype_value !== 'string') return false + + const foundMatchingRequests = matchingRcqCredentialQueriesBasedOnFormat.filter( + (c): c is typeof c & { format: 'mso_mdoc' } => + !!(c.format === 'mso_mdoc' && c.meta && c.meta.doctype_value === doctypeValue) + ) + + // We do not know which one we have to pick based on the meta+format + if (foundMatchingRequests.length > 1 || foundMatchingRequests.length === 0) return false + + // Here we found exactly one match between the requested and registered + const [matchedRequest] = foundMatchingRequests + + // credentialQuery.claims must match or be subset of matchedRequest + + // If the claims is empty, everything within the specific format+meta is allowed + if (!matchedRequest.claims) continue credentialQueryLoop + + // If no specific claims are request, we allow it as the format+meta is allowed to be requested + // but this requests no additional claims + if (!credentialQuery.claims) continue credentialQueryLoop + + // Every claim request in the authorization request must be found in the registration certificate + // for mdoc, this means matching the `path[0]` (namespace) and `path[1]` (value name) + const isEveryClaimAllowedToBeRequested = credentialQuery.claims.every( + (c) => + 'path' in c && + matchedRequest.claims?.some( + (mrc) => 'path' in mrc && c.path[0] === mrc.path[0] && c.path[1] === mrc.path[1] + ) + ) + + if (!isEveryClaimAllowedToBeRequested) return false + + break + } + case 'dc+sd-jwt': + case 'vc+sd-jwt': { + const vctValues = credentialQuery.meta?.vct_values + if (!vctValues) return false + if (credentialQuery.meta?.vct_values?.length === 0) return false + + const foundMatchingRequests = matchingRcqCredentialQueriesBasedOnFormat.filter( + (c): c is typeof c & ({ format: 'dc+sd-jwt' } | { format: 'vc+sd-jwt' }) => + !!( + (c.format === 'dc+sd-jwt' || c.format === 'vc+sd-jwt') && + c.meta?.vct_values && + equalsIgnoreOrder(c.meta.vct_values, vctValues) + ) + ) + + // We do not know which one we have to pick based on the meta+format + if (foundMatchingRequests.length > 1 || foundMatchingRequests.length === 0) return false + + // Here we found exactly one match between the requested and registered + const [matchedRequest] = foundMatchingRequests + + // credentialQuery.claims must match or be subset of matchedRequest + + // If the claims is empty, everything within the specific format+meta is allowed + if (!matchedRequest.claims) continue credentialQueryLoop + + // If no specific claims are request, we allow it as the format+meta is allowed to be requested + // but this requests no additional claims + if (!credentialQuery.claims) continue credentialQueryLoop + + // Every claim request in the authorization request must be found in the registration certificate + // for sd-jwt, this means making sure that every `path[n]` is in the registration certificate + const isEveryClaimAllowedToBeRequested = credentialQuery.claims.every( + (c) => 'path' in c && matchedRequest.claims?.some((mrc) => 'path' in mrc && equalsWithOrder(c.path, mrc.path)) + ) + + if (!isEveryClaimAllowedToBeRequested) return false + + break + } + default: + return false + } + } + + return true +} + +const verifyAuthorizationRequestCallback = + (agentContext: AgentContext) => + async ({ authorizationRequest, jwsService, client }: VerifyAuthorizationRequestOptions) => { + if (!authorizationRequest.verifier_attestations) return + for (const va of authorizationRequest.verifier_attestations) { + // Here we verify it as a registration certificate according to + // https://bmi.usercontent.opencode.de/eudi-wallet/eidas-2.0-architekturkonzept/flows/Wallet-Relying-Party-Authentication/#registration-certificate + if (va.format === 'jwt') { + if (typeof va.data !== 'string') { + throw new Error('Only inline JWTs are supported') + } + + const { isValid } = await jwsService.verifyJws(agentContext, { jws: va.data }) + const jwt = Jwt.fromSerializedJwt(va.data) + + if (!isValid) { + throw new Error('Invalid signature on JWT provided in the verifier_attestations') + } + + if (jwt.header.typ !== 'rc-rp+jwt') { + throw new Error(`only 'rc-rp+jwt' is supported as header typ. Request included: ${jwt.header.typ}`) + } + + const registrationCertificateHeaderSchema = z + .object({ + typ: z.literal('rc-rp+jwt'), + alg: z.string(), + // sprin-d did not define this + x5u: z.string().url().optional(), + // sprin-d did not define this + 'x5t#s256': z.string().optional(), + }) + .passthrough() + + // TODO: does not support intermediaries + const registrationCertificatePayloadSchema = z + .object({ + credentials: z.array( + z.object({ + format: z.string(), + multiple: z.boolean().default(false), + meta: z + .object({ + vct_values: z.array(z.string()).optional(), + doctype_value: z.string().optional(), + }) + .optional(), + trusted_authorities: z + .array(z.object({ type: z.string(), values: z.array(z.string()) })) + .nonempty() + .optional(), + require_cryptographic_holder_binding: z.boolean().default(true), + claims: z + .array( + z.object({ + id: z.string().optional(), + path: z.array(z.string()).nonempty().nonempty(), + values: z.array(z.number().or(z.boolean())).optional(), + }) + ) + .nonempty() + .optional(), + claim_sets: z.array(z.array(z.string())).nonempty().optional(), + }) + ), + contact: z.object({ + website: z.string().url(), + 'e-mail': z.string().email(), + phone: z.string(), + }), + sub: z.string(), + // Should be service + services: z.array(z.object({ lang: z.string(), name: z.string() })), + public_body: z.boolean().default(false), + entitlements: z.array(z.any()), + provided_attestations: z + .array( + z.object({ + format: z.string(), + meta: z.any(), + }) + ) + .optional(), + privacy_policy: z.string().url(), + iat: z.number().optional(), + exp: z.number().optional(), + purpose: z + .array( + z.object({ + locale: z.string().optional(), + lang: z.string().optional(), + name: z.string(), + }) + ) + .optional(), + status: z.any(), + }) + .passthrough() + + registrationCertificateHeaderSchema.parse(jwt.header) + const parsedPayload = registrationCertificatePayloadSchema.parse(jwt.payload.toJson()) + + if (client.scheme !== 'x509_san_dns' && client.scheme !== 'x509_san_uri') { + throw new Error(`Unsupported client scheme ${client.scheme}`) + } + + const [rpCertEncoded] = client.x5c + const rpCert = X509Certificate.fromEncodedCertificate(rpCertEncoded) + + if (rpCert.subject !== parsedPayload.sub) { + throw new Error( + `Subject in the certificate of the auth request: '${rpCert.subject}' is not equal to the subject of the registration certificate: '${parsedPayload.sub}'` + ) + } + + if (parsedPayload.iat && new Date().getTime() / 1000 <= parsedPayload.iat) { + throw new Error('Issued at timestamp of the registration certificate is in the future') + } + + // TODO: check the status of the registration certificate + + // TODO: validate if they can request the credentials!!!! + if (!authorizationRequest.dcql_query) { + throw new Error('DCQL must be used when working registration certificates') + } + const isValidDcqlQuery = isDcqlRequestValidSubsetOfOtherDcqlRequest( + agentContext, + authorizationRequest.dcql_query as DcqlQuery, + parsedPayload as unknown as DcqlQuery + ) + + if (!isValidDcqlQuery) { + throw new Error( + 'DCQL query in the authorization request is not equal or a valid subset of the DCQl query provided in the registration certificate' + ) + } + } else { + throw new Error(`only format of 'jwt' is supported`) + } + } + } From d4b82f9adac865ef1c54d2983d0e453baeaed6f0 Mon Sep 17 00:00:00 2001 From: Berend Sliedrecht Date: Wed, 28 May 2025 15:53:54 +0200 Subject: [PATCH 5/6] chore: found a matching, if more meta+format match Signed-off-by: Berend Sliedrecht --- .../OpenId4vpHolderService.ts | 12 +- .../OpenId4vpHolderServiceOptions.ts | 4 +- .../openid4vc/tests/openid4vc.e2e.test.ts | 340 +++++++++--------- 3 files changed, 181 insertions(+), 175 deletions(-) diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts index 099e4a0130..aae0290505 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts @@ -23,7 +23,6 @@ import { DifPresentationExchangeService, DifPresentationExchangeSubmissionLocation, Hasher, - JwsService, Kms, TypedArrayEncoder, injectable, @@ -146,18 +145,15 @@ export class OpenId4VpHolderService { if (options?.verifyAuthorizationRequestCallback) { try { await options.verifyAuthorizationRequestCallback({ + agentContext, authorizationRequest: verifiedAuthorizationRequest.authorizationRequestPayload as Openid4vpAuthorizationRequest, - jwsService: agentContext.resolve(JwsService), client, }) } catch (e) { - throw new CredoError( - `verificationAuthorizationCallback returned false. User-provided validation failed. cause: ${e}`, - { - cause: e, - } - ) + throw new CredoError(`error during call to User-provided verificationAuthorizationCallback. Cause: ${e}`, { + cause: e, + }) } } diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts index 0e22ecac84..863a119c27 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts @@ -1,4 +1,5 @@ import type { + AgentContext, CanBePromise, DcqlCredentialsForRequest, DcqlQueryResult, @@ -6,7 +7,6 @@ import type { DifPexInputDescriptorToCredentials, DifPresentationExchangeDefinition, EncodedX509Certificate, - JwsService, } from '@credo-ts/core' import { Openid4vpAuthorizationRequestDcApi, ResolvedOpenid4vpAuthorizationRequest } from '@openid4vc/openid4vp' import type { OpenId4VpAuthorizationRequestPayload, Openid4vpAuthorizationRequest } from '../shared' @@ -15,8 +15,8 @@ import type { OpenId4VpAuthorizationRequestPayload, Openid4vpAuthorizationReques export type ParsedTransactionDataEntry = NonNullable[number] export type VerifyAuthorizationRequestOptions = { + agentContext: AgentContext authorizationRequest: Openid4vpAuthorizationRequest | Openid4vpAuthorizationRequestDcApi - jwsService: JwsService // NOTE: `ParsedClientIdentifier` is not exported from @openid4vc/openid4vp client: ResolvedOpenid4vpAuthorizationRequest['client'] } diff --git a/packages/openid4vc/tests/openid4vc.e2e.test.ts b/packages/openid4vc/tests/openid4vc.e2e.test.ts index e69c935e5f..0068f7a61a 100644 --- a/packages/openid4vc/tests/openid4vc.e2e.test.ts +++ b/packages/openid4vc/tests/openid4vc.e2e.test.ts @@ -472,7 +472,7 @@ pUGCFdfNLQIgHGSa5u5ZqUtCrnMiaEageO71rjzBlov0YUH4+6ELioY= holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest( 'openid4vp://?client_id=x509_san_dns%3Afunke-wallet.de&request_uri=https%3A%2F%2Ffunke-wallet.de%2Foid4vp%2Fdraft-24%2Fvalid-request%2Fdcql', { - verifyAuthorizationRequestCallback: verifyAuthorizationRequestCallback(holder.agent.context), + verifyAuthorizationRequestCallback, } ) ).resolves.toBeDefined() @@ -485,7 +485,7 @@ pUGCFdfNLQIgHGSa5u5ZqUtCrnMiaEageO71rjzBlov0YUH4+6ELioY= holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest( 'openid4vp://?client_id=x509_san_dns%3Afunke-wallet.de&request_uri=https%3A%2F%2Ffunke-wallet.de%2Foid4vp%2Fdraft-24%2Foverask%2Fdcql', { - verifyAuthorizationRequestCallback: verifyAuthorizationRequestCallback(holder.agent.context), + verifyAuthorizationRequestCallback, } ) ).rejects.toThrow() @@ -3038,31 +3038,34 @@ function isDcqlRequestValidSubsetOfOtherDcqlRequest( ) // We do not know which one we have to pick based on the meta+format - if (foundMatchingRequests.length > 1 || foundMatchingRequests.length === 0) return false - - // Here we found exactly one match between the requested and registered - const [matchedRequest] = foundMatchingRequests - - // credentialQuery.claims must match or be subset of matchedRequest - - // If the claims is empty, everything within the specific format+meta is allowed - if (!matchedRequest.claims) continue credentialQueryLoop - - // If no specific claims are request, we allow it as the format+meta is allowed to be requested - // but this requests no additional claims - if (!credentialQuery.claims) continue credentialQueryLoop - - // Every claim request in the authorization request must be found in the registration certificate - // for mdoc, this means matching the `path[0]` (namespace) and `path[1]` (value name) - const isEveryClaimAllowedToBeRequested = credentialQuery.claims.every( - (c) => - 'path' in c && - matchedRequest.claims?.some( - (mrc) => 'path' in mrc && c.path[0] === mrc.path[0] && c.path[1] === mrc.path[1] - ) - ) + if (foundMatchingRequests.length === 0) return false + + let foundFullyMatching = false + for (const matchedRequest of foundMatchingRequests) { + // credentialQuery.claims must match or be subset of matchedRequest + + // If the claims is empty, everything within the specific format+meta is allowed + if (!matchedRequest.claims) continue credentialQueryLoop + + // If no specific claims are request, we allow it as the format+meta is allowed to be requested + // but this requests no additional claims + if (!credentialQuery.claims) continue credentialQueryLoop + + // Every claim request in the authorization request must be found in the registration certificate + // for mdoc, this means matching the `path[0]` (namespace) and `path[1]` (value name) + const isEveryClaimAllowedToBeRequested = credentialQuery.claims.every( + (c) => + 'path' in c && + matchedRequest.claims?.some( + (mrc) => 'path' in mrc && c.path[0] === mrc.path[0] && c.path[1] === mrc.path[1] + ) + ) + if (isEveryClaimAllowedToBeRequested) { + foundFullyMatching = true + } + } - if (!isEveryClaimAllowedToBeRequested) return false + if (!foundFullyMatching) return false break } @@ -3082,27 +3085,31 @@ function isDcqlRequestValidSubsetOfOtherDcqlRequest( ) // We do not know which one we have to pick based on the meta+format - if (foundMatchingRequests.length > 1 || foundMatchingRequests.length === 0) return false + if (foundMatchingRequests.length === 0) return false - // Here we found exactly one match between the requested and registered - const [matchedRequest] = foundMatchingRequests + let foundFullyMatching = false + for (const matchedRequest of foundMatchingRequests) { + // credentialQuery.claims must match or be subset of matchedRequest - // credentialQuery.claims must match or be subset of matchedRequest + // If the claims is empty, everything within the specific format+meta is allowed + if (!matchedRequest.claims) continue credentialQueryLoop - // If the claims is empty, everything within the specific format+meta is allowed - if (!matchedRequest.claims) continue credentialQueryLoop + // If no specific claims are request, we allow it as the format+meta is allowed to be requested + // but this requests no additional claims + if (!credentialQuery.claims) continue credentialQueryLoop - // If no specific claims are request, we allow it as the format+meta is allowed to be requested - // but this requests no additional claims - if (!credentialQuery.claims) continue credentialQueryLoop - - // Every claim request in the authorization request must be found in the registration certificate - // for sd-jwt, this means making sure that every `path[n]` is in the registration certificate - const isEveryClaimAllowedToBeRequested = credentialQuery.claims.every( - (c) => 'path' in c && matchedRequest.claims?.some((mrc) => 'path' in mrc && equalsWithOrder(c.path, mrc.path)) - ) + // Every claim request in the authorization request must be found in the registration certificate + // for sd-jwt, this means making sure that every `path[n]` is in the registration certificate + const isEveryClaimAllowedToBeRequested = credentialQuery.claims.every( + (c) => + 'path' in c && matchedRequest.claims?.some((mrc) => 'path' in mrc && equalsWithOrder(c.path, mrc.path)) + ) + if (isEveryClaimAllowedToBeRequested) { + foundFullyMatching = true + } + } - if (!isEveryClaimAllowedToBeRequested) return false + if (!foundFullyMatching) return false break } @@ -3114,144 +3121,147 @@ function isDcqlRequestValidSubsetOfOtherDcqlRequest( return true } -const verifyAuthorizationRequestCallback = - (agentContext: AgentContext) => - async ({ authorizationRequest, jwsService, client }: VerifyAuthorizationRequestOptions) => { - if (!authorizationRequest.verifier_attestations) return - for (const va of authorizationRequest.verifier_attestations) { - // Here we verify it as a registration certificate according to - // https://bmi.usercontent.opencode.de/eudi-wallet/eidas-2.0-architekturkonzept/flows/Wallet-Relying-Party-Authentication/#registration-certificate - if (va.format === 'jwt') { - if (typeof va.data !== 'string') { - throw new Error('Only inline JWTs are supported') - } - - const { isValid } = await jwsService.verifyJws(agentContext, { jws: va.data }) - const jwt = Jwt.fromSerializedJwt(va.data) +const verifyAuthorizationRequestCallback = async ({ + agentContext, + authorizationRequest, + client, +}: VerifyAuthorizationRequestOptions) => { + if (!authorizationRequest.verifier_attestations) return + for (const va of authorizationRequest.verifier_attestations) { + // Here we verify it as a registration certificate according to + // https://bmi.usercontent.opencode.de/eudi-wallet/eidas-2.0-architekturkonzept/flows/Wallet-Relying-Party-Authentication/#registration-certificate + if (va.format === 'jwt') { + if (typeof va.data !== 'string') { + throw new Error('Only inline JWTs are supported') + } - if (!isValid) { - throw new Error('Invalid signature on JWT provided in the verifier_attestations') - } + const jwsService = agentContext.resolve(JwsService) + const { isValid } = await jwsService.verifyJws(agentContext, { jws: va.data }) + const jwt = Jwt.fromSerializedJwt(va.data) - if (jwt.header.typ !== 'rc-rp+jwt') { - throw new Error(`only 'rc-rp+jwt' is supported as header typ. Request included: ${jwt.header.typ}`) - } + if (!isValid) { + throw new Error('Invalid signature on JWT provided in the verifier_attestations') + } - const registrationCertificateHeaderSchema = z - .object({ - typ: z.literal('rc-rp+jwt'), - alg: z.string(), - // sprin-d did not define this - x5u: z.string().url().optional(), - // sprin-d did not define this - 'x5t#s256': z.string().optional(), - }) - .passthrough() + if (jwt.header.typ !== 'rc-rp+jwt') { + throw new Error(`only 'rc-rp+jwt' is supported as header typ. Request included: ${jwt.header.typ}`) + } - // TODO: does not support intermediaries - const registrationCertificatePayloadSchema = z - .object({ - credentials: z.array( + const registrationCertificateHeaderSchema = z + .object({ + typ: z.literal('rc-rp+jwt'), + alg: z.string(), + // sprin-d did not define this + x5u: z.string().url().optional(), + // sprin-d did not define this + 'x5t#s256': z.string().optional(), + }) + .passthrough() + + // TODO: does not support intermediaries + const registrationCertificatePayloadSchema = z + .object({ + credentials: z.array( + z.object({ + format: z.string(), + multiple: z.boolean().default(false), + meta: z + .object({ + vct_values: z.array(z.string()).optional(), + doctype_value: z.string().optional(), + }) + .optional(), + trusted_authorities: z + .array(z.object({ type: z.string(), values: z.array(z.string()) })) + .nonempty() + .optional(), + require_cryptographic_holder_binding: z.boolean().default(true), + claims: z + .array( + z.object({ + id: z.string().optional(), + path: z.array(z.string()).nonempty().nonempty(), + values: z.array(z.number().or(z.boolean())).optional(), + }) + ) + .nonempty() + .optional(), + claim_sets: z.array(z.array(z.string())).nonempty().optional(), + }) + ), + contact: z.object({ + website: z.string().url(), + 'e-mail': z.string().email(), + phone: z.string(), + }), + sub: z.string(), + // Should be service + services: z.array(z.object({ lang: z.string(), name: z.string() })), + public_body: z.boolean().default(false), + entitlements: z.array(z.any()), + provided_attestations: z + .array( z.object({ format: z.string(), - multiple: z.boolean().default(false), - meta: z - .object({ - vct_values: z.array(z.string()).optional(), - doctype_value: z.string().optional(), - }) - .optional(), - trusted_authorities: z - .array(z.object({ type: z.string(), values: z.array(z.string()) })) - .nonempty() - .optional(), - require_cryptographic_holder_binding: z.boolean().default(true), - claims: z - .array( - z.object({ - id: z.string().optional(), - path: z.array(z.string()).nonempty().nonempty(), - values: z.array(z.number().or(z.boolean())).optional(), - }) - ) - .nonempty() - .optional(), - claim_sets: z.array(z.array(z.string())).nonempty().optional(), + meta: z.any(), }) - ), - contact: z.object({ - website: z.string().url(), - 'e-mail': z.string().email(), - phone: z.string(), - }), - sub: z.string(), - // Should be service - services: z.array(z.object({ lang: z.string(), name: z.string() })), - public_body: z.boolean().default(false), - entitlements: z.array(z.any()), - provided_attestations: z - .array( - z.object({ - format: z.string(), - meta: z.any(), - }) - ) - .optional(), - privacy_policy: z.string().url(), - iat: z.number().optional(), - exp: z.number().optional(), - purpose: z - .array( - z.object({ - locale: z.string().optional(), - lang: z.string().optional(), - name: z.string(), - }) - ) - .optional(), - status: z.any(), - }) - .passthrough() + ) + .optional(), + privacy_policy: z.string().url(), + iat: z.number().optional(), + exp: z.number().optional(), + purpose: z + .array( + z.object({ + locale: z.string().optional(), + lang: z.string().optional(), + name: z.string(), + }) + ) + .optional(), + status: z.any(), + }) + .passthrough() - registrationCertificateHeaderSchema.parse(jwt.header) - const parsedPayload = registrationCertificatePayloadSchema.parse(jwt.payload.toJson()) + registrationCertificateHeaderSchema.parse(jwt.header) + const parsedPayload = registrationCertificatePayloadSchema.parse(jwt.payload.toJson()) - if (client.scheme !== 'x509_san_dns' && client.scheme !== 'x509_san_uri') { - throw new Error(`Unsupported client scheme ${client.scheme}`) - } + if (client.scheme !== 'x509_san_dns' && client.scheme !== 'x509_san_uri') { + throw new Error(`Unsupported client scheme ${client.scheme}`) + } - const [rpCertEncoded] = client.x5c - const rpCert = X509Certificate.fromEncodedCertificate(rpCertEncoded) + const [rpCertEncoded] = client.x5c + const rpCert = X509Certificate.fromEncodedCertificate(rpCertEncoded) - if (rpCert.subject !== parsedPayload.sub) { - throw new Error( - `Subject in the certificate of the auth request: '${rpCert.subject}' is not equal to the subject of the registration certificate: '${parsedPayload.sub}'` - ) - } + if (rpCert.subject !== parsedPayload.sub) { + throw new Error( + `Subject in the certificate of the auth request: '${rpCert.subject}' is not equal to the subject of the registration certificate: '${parsedPayload.sub}'` + ) + } - if (parsedPayload.iat && new Date().getTime() / 1000 <= parsedPayload.iat) { - throw new Error('Issued at timestamp of the registration certificate is in the future') - } + if (parsedPayload.iat && new Date().getTime() / 1000 <= parsedPayload.iat) { + throw new Error('Issued at timestamp of the registration certificate is in the future') + } - // TODO: check the status of the registration certificate + // TODO: check the status of the registration certificate - // TODO: validate if they can request the credentials!!!! - if (!authorizationRequest.dcql_query) { - throw new Error('DCQL must be used when working registration certificates') - } - const isValidDcqlQuery = isDcqlRequestValidSubsetOfOtherDcqlRequest( - agentContext, - authorizationRequest.dcql_query as DcqlQuery, - parsedPayload as unknown as DcqlQuery - ) + // TODO: validate if they can request the credentials!!!! + if (!authorizationRequest.dcql_query) { + throw new Error('DCQL must be used when working registration certificates') + } + const isValidDcqlQuery = isDcqlRequestValidSubsetOfOtherDcqlRequest( + agentContext, + authorizationRequest.dcql_query as DcqlQuery, + parsedPayload as unknown as DcqlQuery + ) - if (!isValidDcqlQuery) { - throw new Error( - 'DCQL query in the authorization request is not equal or a valid subset of the DCQl query provided in the registration certificate' - ) - } - } else { - throw new Error(`only format of 'jwt' is supported`) + if (!isValidDcqlQuery) { + throw new Error( + 'DCQL query in the authorization request is not equal or a valid subset of the DCQl query provided in the registration certificate' + ) } + } else { + throw new Error(`only format of 'jwt' is supported`) } } +} From 03b0011fb7823735e4f5e66f2882bf1d0b740e3e Mon Sep 17 00:00:00 2001 From: Berend Sliedrecht Date: Fri, 30 May 2025 12:31:28 +0200 Subject: [PATCH 6/6] chore: removed eudi specific functionality Signed-off-by: Berend Sliedrecht --- .../OpenId4vpHolderService.ts | 37 +- .../OpenId4vpHolderServiceOptions.ts | 9 +- .../openid4vc/tests/openid4vc.e2e.test.ts | 320 +----------------- 3 files changed, 24 insertions(+), 342 deletions(-) diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts index aae0290505..6308e76f25 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderService.ts @@ -28,7 +28,6 @@ import { injectable, } from '@credo-ts/core' import { - Openid4vpAuthorizationRequest, Openid4vpAuthorizationResponse, Openid4vpClient, VpToken, @@ -136,19 +135,23 @@ export class OpenId4VpHolderService { throw new CredoError(`Client scheme '${client.scheme}' is not supported`) } - const pexResult = pex?.presentation_definition - ? await this.handlePresentationExchangeRequest(agentContext, pex.presentation_definition, transactionData) - : undefined - - const dcqlResult = dcql?.query ? await this.handleDcqlRequest(agentContext, dcql.query, transactionData) : undefined + const returnValue = { + authorizationRequestPayload: verifiedAuthorizationRequest.authorizationRequestPayload, + origin: options?.origin, + signedAuthorizationRequest: verifiedAuthorizationRequest.jar + ? { + signer: verifiedAuthorizationRequest.jar?.signer, + payload: verifiedAuthorizationRequest.jar.jwt.payload, + header: verifiedAuthorizationRequest.jar.jwt.header, + } + : undefined, + } if (options?.verifyAuthorizationRequestCallback) { try { await options.verifyAuthorizationRequestCallback({ agentContext, - authorizationRequest: - verifiedAuthorizationRequest.authorizationRequestPayload as Openid4vpAuthorizationRequest, - client, + ...returnValue, }) } catch (e) { throw new CredoError(`error during call to User-provided verificationAuthorizationCallback. Cause: ${e}`, { @@ -157,22 +160,20 @@ export class OpenId4VpHolderService { } } + const pexResult = pex?.presentation_definition + ? await this.handlePresentationExchangeRequest(agentContext, pex.presentation_definition, transactionData) + : undefined + + const dcqlResult = dcql?.query ? await this.handleDcqlRequest(agentContext, dcql.query, transactionData) : undefined + agentContext.config.logger.debug('verified Authorization Request') agentContext.config.logger.debug(`request '${authorizationRequest}'`) return { - authorizationRequestPayload: verifiedAuthorizationRequest.authorizationRequestPayload, + ...returnValue, transactionData: pexResult?.matchedTransactionData ?? dcqlResult?.matchedTransactionData, presentationExchange: pexResult?.pex, dcql: dcqlResult?.dcql, - origin: options?.origin, - signedAuthorizationRequest: verifiedAuthorizationRequest.jar - ? { - signer: verifiedAuthorizationRequest.jar?.signer, - payload: verifiedAuthorizationRequest.jar.jwt.payload, - header: verifiedAuthorizationRequest.jar.jwt.header, - } - : undefined, } } diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts index 863a119c27..173cea82b0 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4vpHolderServiceOptions.ts @@ -8,18 +8,15 @@ import type { DifPresentationExchangeDefinition, EncodedX509Certificate, } from '@credo-ts/core' -import { Openid4vpAuthorizationRequestDcApi, ResolvedOpenid4vpAuthorizationRequest } from '@openid4vc/openid4vp' -import type { OpenId4VpAuthorizationRequestPayload, Openid4vpAuthorizationRequest } from '../shared' +import { ResolvedOpenid4vpAuthorizationRequest } from '@openid4vc/openid4vp' +import type { OpenId4VpAuthorizationRequestPayload } from '../shared' // TODO: export from oid4vp export type ParsedTransactionDataEntry = NonNullable[number] export type VerifyAuthorizationRequestOptions = { agentContext: AgentContext - authorizationRequest: Openid4vpAuthorizationRequest | Openid4vpAuthorizationRequestDcApi - // NOTE: `ParsedClientIdentifier` is not exported from @openid4vc/openid4vp - client: ResolvedOpenid4vpAuthorizationRequest['client'] -} +} & Pick export type VerifyAuthorizationRequestCallback = (options: VerifyAuthorizationRequestOptions) => CanBePromise diff --git a/packages/openid4vc/tests/openid4vc.e2e.test.ts b/packages/openid4vc/tests/openid4vc.e2e.test.ts index 0068f7a61a..36b7ecc584 100644 --- a/packages/openid4vc/tests/openid4vc.e2e.test.ts +++ b/packages/openid4vc/tests/openid4vc.e2e.test.ts @@ -1,16 +1,8 @@ -import type { - AgentContext, - DcqlQuery, - DifPresentationExchangeDefinitionV2, - Mdoc, - MdocDeviceResponse, - SdJwtVc, -} from '@credo-ts/core' +import type { DcqlQuery, DifPresentationExchangeDefinitionV2, Mdoc, MdocDeviceResponse, SdJwtVc } from '@credo-ts/core' import { ClaimFormat, CredoError, DateOnly, - DcqlService, DidsApi, JwsService, Jwt, @@ -24,9 +16,6 @@ import { X509Certificate, X509Module, X509Service, - deepEquality, - equalsIgnoreOrder, - equalsWithOrder, getPublicJwkFromVerificationMethod, parseDid, w3cDate, @@ -40,7 +29,6 @@ import { } from '@openid4vc/oauth2' import { AuthorizationFlow } from '@openid4vc/openid4vci' import express, { type Express } from 'express' -import { z } from 'zod' import { InMemoryWalletModule } from '../../../tests/InMemoryWalletModule' import { setupNockToExpress } from '../../../tests/nockToExpress' import { TenantsModule } from '../../tenants/src' @@ -52,7 +40,7 @@ import { OpenId4VcVerificationSessionState, OpenId4VcVerifierModule, } from '../src' -import type { OpenId4VciCredentialBindingResolver, VerifyAuthorizationRequestOptions } from '../src/openid4vc-holder' +import type { OpenId4VciCredentialBindingResolver } from '../src/openid4vc-holder' import { getOid4vcCallbacks } from '../src/shared/callbacks' import type { AgentType, TenantType } from './utils' import { @@ -465,32 +453,6 @@ pUGCFdfNLQIgHGSa5u5ZqUtCrnMiaEageO71rjzBlov0YUH4+6ELioY= ).resolves.toBeDefined() }) - it('e2e flow with tenants, holder verification callback for authorization request succeeds', async () => { - const holderTenant = await holder.agent.modules.tenants.getTenantAgent({ tenantId: holder1.tenantId }) - - await expect( - holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest( - 'openid4vp://?client_id=x509_san_dns%3Afunke-wallet.de&request_uri=https%3A%2F%2Ffunke-wallet.de%2Foid4vp%2Fdraft-24%2Fvalid-request%2Fdcql', - { - verifyAuthorizationRequestCallback, - } - ) - ).resolves.toBeDefined() - }) - - it('e2e flow with tenants, holder verification callback for authorization request fails with overasking', async () => { - const holderTenant = await holder.agent.modules.tenants.getTenantAgent({ tenantId: holder1.tenantId }) - - await expect( - holderTenant.modules.openId4VcHolder.resolveOpenId4VpAuthorizationRequest( - 'openid4vp://?client_id=x509_san_dns%3Afunke-wallet.de&request_uri=https%3A%2F%2Ffunke-wallet.de%2Foid4vp%2Fdraft-24%2Foverask%2Fdcql', - { - verifyAuthorizationRequestCallback, - } - ) - ).rejects.toThrow() - }) - it('e2e flow with tenants, verifier endpoints verifying a jwt-vc', async () => { const holderTenant = await holder.agent.modules.tenants.getTenantAgent({ tenantId: holder1.tenantId }) const verifierTenant1 = await verifier.agent.modules.tenants.getTenantAgent({ tenantId: verifier1.tenantId }) @@ -2987,281 +2949,3 @@ pUGCFdfNLQIgHGSa5u5ZqUtCrnMiaEageO71rjzBlov0YUH4+6ELioY= }) }) }) - -// TODO: might move this to the dcql module and expose it as a util function -function isDcqlRequestValidSubsetOfOtherDcqlRequest( - agentContext: AgentContext, - arq: DcqlQuery, - rcq: DcqlQuery -): boolean { - const dcqlService = agentContext.resolve(DcqlService) - dcqlService.validateDcqlQuery(arq) - // TODO: validate required the `id` property, which is not in the rcq - // const rcqQuery = dcqlService.validateDcqlQuery(arq) - - if (rcq.credential_sets) { - agentContext.config.logger.warn( - 'credential_sets are not allowed on the dcql query for the registration certificate' - ) - return false - } - - if (rcq.credentials.some((c) => c.id)) { - agentContext.config.logger.warn( - 'credentials[n].id is not allowed on the dcql query for the registration certificate' - ) - return false - } - - // Short-circuit for exact match - if (deepEquality(arq.credentials, rcq.credentials)) return true - - // only sd-jwt and mdoc are supported - if (arq.credentials.some((c) => c.format !== 'mso_mdoc' && c.format !== 'vc+sd-jwt' && c.format !== 'dc+sd-jwt')) { - return false - } - - credentialQueryLoop: for (const credentialQuery of arq.credentials) { - const matchingRcqCredentialQueriesBasedOnFormat = rcq.credentials.filter((c) => c.format === credentialQuery.format) - - if (matchingRcqCredentialQueriesBasedOnFormat.length === 0) return false - - switch (credentialQuery.format) { - case 'mso_mdoc': { - const doctypeValue = credentialQuery.meta?.doctype_value - if (!doctypeValue) return false - if (typeof credentialQuery.meta?.doctype_value !== 'string') return false - - const foundMatchingRequests = matchingRcqCredentialQueriesBasedOnFormat.filter( - (c): c is typeof c & { format: 'mso_mdoc' } => - !!(c.format === 'mso_mdoc' && c.meta && c.meta.doctype_value === doctypeValue) - ) - - // We do not know which one we have to pick based on the meta+format - if (foundMatchingRequests.length === 0) return false - - let foundFullyMatching = false - for (const matchedRequest of foundMatchingRequests) { - // credentialQuery.claims must match or be subset of matchedRequest - - // If the claims is empty, everything within the specific format+meta is allowed - if (!matchedRequest.claims) continue credentialQueryLoop - - // If no specific claims are request, we allow it as the format+meta is allowed to be requested - // but this requests no additional claims - if (!credentialQuery.claims) continue credentialQueryLoop - - // Every claim request in the authorization request must be found in the registration certificate - // for mdoc, this means matching the `path[0]` (namespace) and `path[1]` (value name) - const isEveryClaimAllowedToBeRequested = credentialQuery.claims.every( - (c) => - 'path' in c && - matchedRequest.claims?.some( - (mrc) => 'path' in mrc && c.path[0] === mrc.path[0] && c.path[1] === mrc.path[1] - ) - ) - if (isEveryClaimAllowedToBeRequested) { - foundFullyMatching = true - } - } - - if (!foundFullyMatching) return false - - break - } - case 'dc+sd-jwt': - case 'vc+sd-jwt': { - const vctValues = credentialQuery.meta?.vct_values - if (!vctValues) return false - if (credentialQuery.meta?.vct_values?.length === 0) return false - - const foundMatchingRequests = matchingRcqCredentialQueriesBasedOnFormat.filter( - (c): c is typeof c & ({ format: 'dc+sd-jwt' } | { format: 'vc+sd-jwt' }) => - !!( - (c.format === 'dc+sd-jwt' || c.format === 'vc+sd-jwt') && - c.meta?.vct_values && - equalsIgnoreOrder(c.meta.vct_values, vctValues) - ) - ) - - // We do not know which one we have to pick based on the meta+format - if (foundMatchingRequests.length === 0) return false - - let foundFullyMatching = false - for (const matchedRequest of foundMatchingRequests) { - // credentialQuery.claims must match or be subset of matchedRequest - - // If the claims is empty, everything within the specific format+meta is allowed - if (!matchedRequest.claims) continue credentialQueryLoop - - // If no specific claims are request, we allow it as the format+meta is allowed to be requested - // but this requests no additional claims - if (!credentialQuery.claims) continue credentialQueryLoop - - // Every claim request in the authorization request must be found in the registration certificate - // for sd-jwt, this means making sure that every `path[n]` is in the registration certificate - const isEveryClaimAllowedToBeRequested = credentialQuery.claims.every( - (c) => - 'path' in c && matchedRequest.claims?.some((mrc) => 'path' in mrc && equalsWithOrder(c.path, mrc.path)) - ) - if (isEveryClaimAllowedToBeRequested) { - foundFullyMatching = true - } - } - - if (!foundFullyMatching) return false - - break - } - default: - return false - } - } - - return true -} - -const verifyAuthorizationRequestCallback = async ({ - agentContext, - authorizationRequest, - client, -}: VerifyAuthorizationRequestOptions) => { - if (!authorizationRequest.verifier_attestations) return - for (const va of authorizationRequest.verifier_attestations) { - // Here we verify it as a registration certificate according to - // https://bmi.usercontent.opencode.de/eudi-wallet/eidas-2.0-architekturkonzept/flows/Wallet-Relying-Party-Authentication/#registration-certificate - if (va.format === 'jwt') { - if (typeof va.data !== 'string') { - throw new Error('Only inline JWTs are supported') - } - - const jwsService = agentContext.resolve(JwsService) - const { isValid } = await jwsService.verifyJws(agentContext, { jws: va.data }) - const jwt = Jwt.fromSerializedJwt(va.data) - - if (!isValid) { - throw new Error('Invalid signature on JWT provided in the verifier_attestations') - } - - if (jwt.header.typ !== 'rc-rp+jwt') { - throw new Error(`only 'rc-rp+jwt' is supported as header typ. Request included: ${jwt.header.typ}`) - } - - const registrationCertificateHeaderSchema = z - .object({ - typ: z.literal('rc-rp+jwt'), - alg: z.string(), - // sprin-d did not define this - x5u: z.string().url().optional(), - // sprin-d did not define this - 'x5t#s256': z.string().optional(), - }) - .passthrough() - - // TODO: does not support intermediaries - const registrationCertificatePayloadSchema = z - .object({ - credentials: z.array( - z.object({ - format: z.string(), - multiple: z.boolean().default(false), - meta: z - .object({ - vct_values: z.array(z.string()).optional(), - doctype_value: z.string().optional(), - }) - .optional(), - trusted_authorities: z - .array(z.object({ type: z.string(), values: z.array(z.string()) })) - .nonempty() - .optional(), - require_cryptographic_holder_binding: z.boolean().default(true), - claims: z - .array( - z.object({ - id: z.string().optional(), - path: z.array(z.string()).nonempty().nonempty(), - values: z.array(z.number().or(z.boolean())).optional(), - }) - ) - .nonempty() - .optional(), - claim_sets: z.array(z.array(z.string())).nonempty().optional(), - }) - ), - contact: z.object({ - website: z.string().url(), - 'e-mail': z.string().email(), - phone: z.string(), - }), - sub: z.string(), - // Should be service - services: z.array(z.object({ lang: z.string(), name: z.string() })), - public_body: z.boolean().default(false), - entitlements: z.array(z.any()), - provided_attestations: z - .array( - z.object({ - format: z.string(), - meta: z.any(), - }) - ) - .optional(), - privacy_policy: z.string().url(), - iat: z.number().optional(), - exp: z.number().optional(), - purpose: z - .array( - z.object({ - locale: z.string().optional(), - lang: z.string().optional(), - name: z.string(), - }) - ) - .optional(), - status: z.any(), - }) - .passthrough() - - registrationCertificateHeaderSchema.parse(jwt.header) - const parsedPayload = registrationCertificatePayloadSchema.parse(jwt.payload.toJson()) - - if (client.scheme !== 'x509_san_dns' && client.scheme !== 'x509_san_uri') { - throw new Error(`Unsupported client scheme ${client.scheme}`) - } - - const [rpCertEncoded] = client.x5c - const rpCert = X509Certificate.fromEncodedCertificate(rpCertEncoded) - - if (rpCert.subject !== parsedPayload.sub) { - throw new Error( - `Subject in the certificate of the auth request: '${rpCert.subject}' is not equal to the subject of the registration certificate: '${parsedPayload.sub}'` - ) - } - - if (parsedPayload.iat && new Date().getTime() / 1000 <= parsedPayload.iat) { - throw new Error('Issued at timestamp of the registration certificate is in the future') - } - - // TODO: check the status of the registration certificate - - // TODO: validate if they can request the credentials!!!! - if (!authorizationRequest.dcql_query) { - throw new Error('DCQL must be used when working registration certificates') - } - const isValidDcqlQuery = isDcqlRequestValidSubsetOfOtherDcqlRequest( - agentContext, - authorizationRequest.dcql_query as DcqlQuery, - parsedPayload as unknown as DcqlQuery - ) - - if (!isValidDcqlQuery) { - throw new Error( - 'DCQL query in the authorization request is not equal or a valid subset of the DCQl query provided in the registration certificate' - ) - } - } else { - throw new Error(`only format of 'jwt' is supported`) - } - } -}