diff --git a/README.md b/README.md index fb05ef3..1cd967b 100644 --- a/README.md +++ b/README.md @@ -75,11 +75,11 @@ Open three terminal windows, and then run the following: npx ngrok http 3001 ``` -Copy the https url from the ngrok command and set that as the `AGENT_HOST` and `AGENT_DNS` +Copy the https url from the ngrok command and set that as the `AGENT_HOST` ```bash cd agent -AGENT_DNS=30f9-58-136-114-148.ngrok-free.app AGENT_HOST=https://30f9-58-136-114-148.ngrok-free.app pnpm dev +AGENT_HOST=https://30f9-58-136-114-148.ngrok-free.app pnpm dev ``` ```bash diff --git a/agent/package.json b/agent/package.json index b74f0eb..e6572c3 100644 --- a/agent/package.json +++ b/agent/package.json @@ -1,12 +1,12 @@ { "name": "agent", "dependencies": { - "@credo-ts/askar": "0.6.0-alpha-20241119125554", - "@credo-ts/core": "0.6.0-alpha-20241119125554", - "@credo-ts/node": "0.6.0-alpha-20241119125554", - "@credo-ts/openid4vc": "0.6.0-alpha-20241119125554", + "@credo-ts/askar": "0.6.0-pr-2100-20241124131043", + "@credo-ts/core": "0.6.0-pr-2100-20241124131043", + "@credo-ts/node": "0.6.0-pr-2100-20241124131043", + "@credo-ts/openid4vc": "0.6.0-pr-2100-20241124131043", "@hyperledger/aries-askar-nodejs": "^0.2.3", - "@animo-id/mdoc": "^0.2.38", + "@animo-id/mdoc": "^0.2.39", "cors": "^2.8.5", "dotenv": "^16.4.5", "express": "^4.18.2", @@ -27,8 +27,10 @@ "dev": "tsx watch -r dotenv/config src/server.ts dotenv_config_path=.env.development" }, "pnpm": { - "patchedDependencies": { - "@sphereon/did-auth-siop@0.16.1-fix.173": "patches/@sphereon__did-auth-siop@0.16.1-fix.173.patch" + "overrides": { + "@sphereon/did-auth-siop": "https://gitpkg.vercel.app/animo/OID4VC/packages/siop-oid4vp?funke", + "@sphereon/jarm": "https://gitpkg.vercel.app/animo/OID4VC/packages/jarm?funke", + "@sphereon/oid4vc-common": "https://gitpkg.vercel.app/animo/OID4VC/packages/common?funke" } } } diff --git a/agent/src/constants.ts b/agent/src/constants.ts index 98f0994..ffeb29a 100644 --- a/agent/src/constants.ts +++ b/agent/src/constants.ts @@ -3,7 +3,7 @@ if (!process.env.P256_SEED || !process.env.AGENT_HOST || !process.env.AGENT_WALL } const AGENT_HOST = process.env.AGENT_HOST -const AGENT_DNS = process.env.AGENT_DNS ?? process.env.AGENT_HOST.replace('https://', '') +const AGENT_DNS = AGENT_HOST.replace('https://', '') const AGENT_WALLET_KEY = process.env.AGENT_WALLET_KEY const P256_SEED = process.env.P256_SEED diff --git a/agent/src/endpoints.ts b/agent/src/endpoints.ts index dbb6b4e..6978780 100644 --- a/agent/src/endpoints.ts +++ b/agent/src/endpoints.ts @@ -1,7 +1,7 @@ import { DifPresentationExchangeService, JsonTransformer, - KeyType, + Jwt, MdocDeviceResponse, RecordNotFoundError, W3cJsonLdVerifiablePresentation, @@ -80,9 +80,11 @@ apiRouter.post('/offers/create', async (request: Request, response: Response) => apiRouter.get('/x509', async (_, response: Response) => { const certificate = getX509Certificate() - + const instance = X509Certificate.fromEncodedCertificate(certificate) return response.json({ - certificate, + base64: instance.toString('base64'), + pem: instance.toString('pem'), + decoded: instance.toString('text'), }) }) @@ -195,14 +197,20 @@ apiRouter.post('/offers/receive', async (request: Request, response: Response) = apiRouter.get('/verifier', async (_, response: Response) => { return response.json({ - presentationRequests: verifiers.flatMap((i) => - i.presentationRequests.map((c) => { + presentationRequests: verifiers.flatMap((i) => [ + ...i.presentationRequests.map((c) => { return { - display: c.name, + display: `${i.clientMetadata.client_name} - ${c.name} - DIF PEX`, id: c.id, } - }) - ), + }), + ...i.dcqlRequests.map((c) => { + return { + display: `${i.clientMetadata.client_name} - ${c.name} - DCQL`, + id: c.id, + } + }), + ]), }) }) @@ -225,13 +233,17 @@ apiRouter.post('/requests/create', async (request: Request, response: Response) }) } - const verifierId = verifiers.find((a) => a.presentationRequests.find((r) => r.id === definition.id))?.verifierId + const verifierId = verifiers.find( + (a) => + a.presentationRequests.find((r) => r.id === definition.id) ?? a.dcqlRequests.find((r) => r.id === definition.id) + )?.verifierId if (!verifierId) { return response.status(404).json({ error: 'Verifier not found', }) } const verifier = await getVerifier(verifierId) + console.log('Requesting definition', JSON.stringify(definition, null, 2)) const { authorizationRequest, verificationSession } = await agent.modules.openId4VcVerifier.createAuthorizationRequest({ @@ -242,17 +254,37 @@ apiRouter.post('/requests/create', async (request: Request, response: Response) // FIXME: remove issuer param from credo as we can infer it from the url issuer: `${AGENT_HOST}/siop/${verifier.verifierId}/authorize`, }, - presentationExchange: { - definition, - }, + presentationExchange: + 'input_descriptors' in definition + ? { + definition, + } + : undefined, + dcql: + 'credentials' in definition + ? { + query: definition, + } + : undefined, responseMode: createPresentationRequestBody.responseMode, }) console.log(authorizationRequest) + const authorizationRequestJwt = Jwt.fromSerializedJwt(verificationSession.authorizationRequestJwt) + const dcqlQuery = authorizationRequestJwt.payload.additionalClaims.dcql_query + const presentationDefinition = authorizationRequestJwt.payload.additionalClaims.presentation_definition + return response.json({ authorizationRequestUri: authorizationRequest.replace('openid4vp://', createPresentationRequestBody.requestScheme), verificationSessionId: verificationSession.id, + responseStatus: verificationSession.state, + dcqlQuery: dcqlQuery ? JSON.parse(dcqlQuery as string) : undefined, + definition: presentationDefinition, + authorizationRequest: { + payload: authorizationRequestJwt.payload.toJson(), + header: authorizationRequestJwt.header, + }, }) }) @@ -266,63 +298,87 @@ apiRouter.get('/requests/:verificationSessionId', async (request, response) => { try { const verificationSession = await agent.modules.openId4VcVerifier.getVerificationSessionById(verificationSessionId) + const authorizationRequestJwt = Jwt.fromSerializedJwt(verificationSession.authorizationRequestJwt) + const authorizationRequest = { + payload: authorizationRequestJwt.payload.toJson(), + header: authorizationRequestJwt.header, + } + const dcqlQuery = authorizationRequestJwt.payload.additionalClaims.dcql_query + const presentationDefinition = authorizationRequestJwt.payload.additionalClaims.presentation_definition + if (verificationSession.state === OpenId4VcVerificationSessionState.ResponseVerified) { const verified = await agent.modules.openId4VcVerifier.getVerifiedAuthorizationResponse(verificationSessionId) - console.log(verified.presentationExchange?.presentations) + console.log(verified.dcql?.presentationResult) const presentations = await Promise.all( - verified.presentationExchange?.presentations.map(async (presentation) => { - if (presentation instanceof W3cJsonLdVerifiablePresentation) { - return { - pretty: presentation.toJson(), - encoded: presentation.toJson(), + (verified.presentationExchange?.presentations ?? Object.values(verified.dcql?.presentation ?? {})).map( + async (presentation) => { + if (presentation instanceof W3cJsonLdVerifiablePresentation) { + return { + pretty: presentation.toJson(), + encoded: presentation.toJson(), + } } - } - if (presentation instanceof W3cJwtVerifiablePresentation) { - return { - pretty: JsonTransformer.toJSON(presentation.presentation), - encoded: presentation.serializedJwt, + if (presentation instanceof W3cJwtVerifiablePresentation) { + return { + pretty: JsonTransformer.toJSON(presentation.presentation), + encoded: presentation.serializedJwt, + } } - } - if (presentation instanceof MdocDeviceResponse) { - return { - pretty: JsonTransformer.toJSON({ - documents: presentation.documents.map((doc) => ({ - doctype: doc.docType, - alg: doc.alg, - base64Url: doc.base64Url, - validityInfo: doc.validityInfo, - deviceSignedNamespaces: doc.deviceSignedNamespaces, - issuerSignedNamespaces: doc.issuerSignedNamespaces, - })), - }), - encoded: presentation.base64Url, + if (presentation instanceof MdocDeviceResponse) { + return { + pretty: JsonTransformer.toJSON({ + documents: presentation.documents.map((doc) => ({ + doctype: doc.docType, + alg: doc.alg, + base64Url: doc.base64Url, + validityInfo: doc.validityInfo, + deviceSignedNamespaces: doc.deviceSignedNamespaces, + issuerSignedNamespaces: doc.issuerSignedNamespaces, + })), + }), + encoded: presentation.base64Url, + } } - } - return { - pretty: { - ...presentation, - compact: undefined, - }, - encoded: presentation.compact, + return { + pretty: { + ...presentation, + compact: undefined, + }, + encoded: presentation.compact, + } } - }) ?? [] + ) ?? [] ) + const dcqlSubmission = verified.dcql + ? Object.keys(verified.dcql.presentation).map((key, index) => ({ + queryCredentialId: key, + presentationIndex: index, + })) + : undefined + console.log('presentations', presentations) return response.json({ verificationSessionId: verificationSession.id, responseStatus: verificationSession.state, error: verificationSession.errorMessage, + authorizationRequest, presentations: presentations, + submission: verified.presentationExchange?.submission, definition: verified.presentationExchange?.definition, + + dcqlQuery: dcqlQuery ? JSON.parse(dcqlQuery as string) : undefined, + dcqlSubmission: verified.dcql + ? { ...verified.dcql.presentationResult, vpTokenMapping: dcqlSubmission } + : undefined, }) } @@ -330,6 +386,9 @@ apiRouter.get('/requests/:verificationSessionId', async (request, response) => { verificationSessionId: verificationSession.id, responseStatus: verificationSession.state, error: verificationSession.errorMessage, + authorizationRequest, + definition: presentationDefinition, + dcqlQuery: dcqlQuery ? JSON.parse(dcqlQuery as string) : undefined, }) } catch (error) { if (error instanceof RecordNotFoundError) { diff --git a/agent/src/issuer.ts b/agent/src/issuer.ts index aea554c..2a4f73c 100644 --- a/agent/src/issuer.ts +++ b/agent/src/issuer.ts @@ -1,4 +1,4 @@ -import { ClaimFormat } from '@credo-ts/core' +import { ClaimFormat, X509Certificate } from '@credo-ts/core' import { OpenId4VcVerifierApi, type OpenId4VciCreateIssuerOptions, @@ -14,8 +14,8 @@ import { mobileDriversLicenseMdoc, mobileDriversLicenseSdJwt } from './issuers/i import { getX509Certificate } from './keyMethods' import { DateOnly, oneYearInMilliseconds, serverStartupTimeInMilliseconds, tenDaysInMilliseconds } from './utils/date' import { getVerifier } from './verifier' -import { pidSdJwtInputDescriptor } from './verifiers/util' import { animoVerifier } from './verifiers/animo' +import { pidMdocInputDescriptor, pidSdJwtInputDescriptor } from './verifiers/util' export async function createOrUpdateIssuer(options: OpenId4VciCreateIssuerOptions & { issuerId: string }) { if (await doesIssuerExist(options.issuerId)) { @@ -73,7 +73,19 @@ export const getVerificationSessionForIssuanceSession: OpenId4VciGetVerification pidSdJwtInputDescriptor({ id: 'pid-sd-jwt-issuance', fields: ['given_name', 'family_name', 'birthdate'], + group: 'PID', }), + pidMdocInputDescriptor({ + fields: ['given_name', 'family_name', 'birth_date', 'issuing_country', 'issuing_authority'], + group: 'PID', + }), + ], + submission_requirements: [ + { + rule: 'pick', + count: 1, + from: 'PID', + }, ], }, }, @@ -106,37 +118,86 @@ export const credentialRequestToCredentialMapper: OpenId4VciCredentialRequestToC (credentialData.credentialConfigurationId === mobileDriversLicenseMdoc.id || credentialData.credentialConfigurationId === mobileDriversLicenseSdJwt.id) ) { - const descriptor = verification?.presentationExchange.descriptors.find( + const descriptor = verification?.presentationExchange?.descriptors.find( (descriptor) => descriptor.descriptor.id === 'pid-sd-jwt-issuance' ) - if (credentialData.format === ClaimFormat.SdJwtVc && descriptor && descriptor.format === ClaimFormat.SdJwtVc) { - const { authorization, credential, ...restCredentialData } = credentialData - - return { - ...restCredentialData, - credentials: holderBindings.map((holderBinding) => ({ - ...credential, - payload: { - ...credential.payload, - given_name: descriptor.credential.prettyClaims.given_name, - family_name: descriptor.credential.prettyClaims.family_name, - birth_date: descriptor.credential.prettyClaims.birth_date, - document_number: 'Z021AB37X13', - un_distinguishing_sign: 'D', - - issuing_authority: descriptor.credential.prettyClaims.issuing_authority, - issue_date: new DateOnly(new Date(serverStartupTimeInMilliseconds - tenDaysInMilliseconds).toISOString()), - expiry_date: new DateOnly(new Date(serverStartupTimeInMilliseconds + oneYearInMilliseconds).toISOString()), - issuing_country: descriptor.credential.prettyClaims.issuing_country, - }, - holder: holderBinding, - issuer: { - method: 'x5c', - x5c: [x509Certificate], - issuer: AGENT_HOST, - }, - })), - } satisfies OpenId4VciSignSdJwtCredentials + + // We allow receiving the PID in both SD-JWT and mdoc when issuing in sd-jwt or mdoc format + if (descriptor?.format === ClaimFormat.SdJwtVc || descriptor?.format === ClaimFormat.MsoMdoc) { + const formatSpecificClaims = + descriptor.format === ClaimFormat.SdJwtVc + ? { + given_name: descriptor.credential.prettyClaims.given_name, + family_name: descriptor.credential.prettyClaims.family_name, + birth_date: descriptor.credential.prettyClaims.birthdate, + + issuing_authority: descriptor.credential.prettyClaims.issuing_authority, + issuing_country: descriptor.credential.prettyClaims.issuing_country, + } + : { + given_name: descriptor.credential.issuerSignedNamespaces['eu.europa.ec.eudi.pid.1'].given_name, + family_name: descriptor.credential.issuerSignedNamespaces['eu.europa.ec.eudi.pid.1'].family_name, + birth_date: descriptor.credential.issuerSignedNamespaces['eu.europa.ec.eudi.pid.1'].birth_date, + + issuing_authority: + descriptor.credential.issuerSignedNamespaces['eu.europa.ec.eudi.pid.1'].issuing_authority, + issuing_country: descriptor.credential.issuerSignedNamespaces['eu.europa.ec.eudi.pid.1'].issuing_country, + } + + if (credentialData.format === ClaimFormat.SdJwtVc) { + const { authorization, credential, ...restCredentialData } = credentialData + + return { + ...restCredentialData, + credentials: holderBindings.map((holderBinding) => ({ + ...credential, + payload: { + ...credential.payload, + ...formatSpecificClaims, + issue_date: new DateOnly(new Date(serverStartupTimeInMilliseconds - tenDaysInMilliseconds).toISOString()), + expiry_date: new DateOnly( + new Date(serverStartupTimeInMilliseconds + oneYearInMilliseconds).toISOString() + ), + }, + holder: holderBinding, + issuer: { + method: 'x5c', + x5c: [x509Certificate], + issuer: AGENT_HOST, + }, + })), + } satisfies OpenId4VciSignSdJwtCredentials + } + + if (credentialData.format === ClaimFormat.MsoMdoc) { + const { authorization, credential, ...restCredentialData } = credentialData + + return { + ...restCredentialData, + credentials: holderBindings.map((holderBinding) => ({ + ...credential, + namespaces: { + 'org.iso.18013.5.1': { + ...credential.namespaces['org.iso.18013.5.1'], + ...formatSpecificClaims, + + // NOTE: MUST be same as the C= value in the issuer cert for mdoc (checked by libs) + issuing_country: 'NL', + + issue_date: new DateOnly( + new Date(serverStartupTimeInMilliseconds - tenDaysInMilliseconds).toISOString() + ), + expiry_date: new DateOnly( + new Date(serverStartupTimeInMilliseconds + oneYearInMilliseconds).toISOString() + ), + }, + }, + + holderKey: holderBinding.key, + issuerCertificate: x509Certificate, + })), + } satisfies OpenId4VciSignMdocCredentials + } } } diff --git a/agent/src/issuers/infrastruktur.ts b/agent/src/issuers/infrastruktur.ts index 998914b..100d8cb 100644 --- a/agent/src/issuers/infrastruktur.ts +++ b/agent/src/issuers/infrastruktur.ts @@ -37,9 +37,9 @@ const mobileDriversLicensePayload = { portrait: new Uint8Array(erikaPortrait), un_distinguishing_sign: 'D', issuing_authority: 'Bundesrepublik Deutschland', - issue_date: new DateOnly(new Date(serverStartupTimeInMilliseconds - tenDaysInMilliseconds).toISOString()), - expiry_date: new DateOnly(new Date(serverStartupTimeInMilliseconds + oneYearInMilliseconds).toISOString()), - issuing_country: 'DE', + issue_date: new Date(serverStartupTimeInMilliseconds - tenDaysInMilliseconds), + expiry_date: new Date(serverStartupTimeInMilliseconds + oneYearInMilliseconds), + issuing_country: 'NL', driving_priviliges: [ { vehicle_category_code: 'B', @@ -75,8 +75,13 @@ export const mobileDriversLicenseMdoc = { cryptographic_suites_supported: [JwaSignatureAlgorithm.ES256], id: 'mobile-drivers-license-mdoc', scope: 'mobile-drivers-license-mdoc', - doctype: 'org.iso.18013.5.1.mDL.1', + doctype: 'org.iso.18013.5.1.mDL', display: [mobileDriversLicenseDisplay], + proof_types_supported: { + jwt: { + proof_signing_alg_values_supported: [JwaSignatureAlgorithm.ES256], + }, + }, } as const satisfies OpenId4VciCredentialConfigurationSupportedWithFormats export const mobileDriversLicenseMdocData = { @@ -85,11 +90,20 @@ export const mobileDriversLicenseMdocData = { credential: { docType: mobileDriversLicenseMdoc.doctype, namespaces: { - [mobileDriversLicenseMdoc.doctype]: mobileDriversLicensePayload, + 'org.iso.18013.5.1': { + ...mobileDriversLicensePayload, + // Causes issue in google identity credential if not string + birth_date: mobileDriversLicensePayload.birth_date.toISOString(), + }, }, validityInfo: { validFrom: mobileDriversLicensePayload.issue_date, validUntil: mobileDriversLicensePayload.expiry_date, + + // Causes issue in google identity credential if not present + // Update half year before expiry + expectedUpdate: new Date(serverStartupTimeInMilliseconds + Math.floor(oneYearInMilliseconds / 2)), + signed: mobileDriversLicensePayload.issue_date, }, }, authorization: { type: 'presentation' }, @@ -103,6 +117,11 @@ export const mobileDriversLicenseSdJwt = { scope: 'mobile-drivers-license-sd-jwt', vct: 'https://example.eudi.ec.europa.eu/mdl/1', display: [mobileDriversLicenseDisplay], + proof_types_supported: { + jwt: { + proof_signing_alg_values_supported: [JwaSignatureAlgorithm.ES256], + }, + }, } as const satisfies OpenId4VciCredentialConfigurationSupportedWithFormats export const mobileDriversLicenseSdJwtData = { diff --git a/agent/src/issuers/koln.ts b/agent/src/issuers/koln.ts index 8277bf1..92cc4ed 100644 --- a/agent/src/issuers/koln.ts +++ b/agent/src/issuers/koln.ts @@ -48,6 +48,11 @@ export const certificateOfResidenceMdoc = { scope: 'certificate-of-residence-mdoc', doctype: 'eu.europa.ec.eudi.cor.1', display: [certificateOfResidenceDisplay], + proof_types_supported: { + jwt: { + proof_signing_alg_values_supported: [JwaSignatureAlgorithm.ES256], + }, + }, } as const satisfies OpenId4VciCredentialConfigurationSupportedWithFormats export const certificateOfResidenceMdocData = { @@ -75,6 +80,11 @@ export const certificateOfResidenceSdJwt = { scope: 'certificate-of-residence-sd-jwt', vct: 'https://example.eudi.ec.europa.eu/cor/1', display: [certificateOfResidenceDisplay], + proof_types_supported: { + jwt: { + proof_signing_alg_values_supported: [JwaSignatureAlgorithm.ES256], + }, + }, } as const satisfies OpenId4VciCredentialConfigurationSupportedWithFormats export const certificateOfResidenceSdJwtData = { diff --git a/agent/src/issuers/steuern.ts b/agent/src/issuers/steuern.ts index 6f8f5f8..78dd20d 100644 --- a/agent/src/issuers/steuern.ts +++ b/agent/src/issuers/steuern.ts @@ -49,6 +49,11 @@ export const steuerIdMdoc = { scope: 'steuer-id-mdoc', doctype: 'eu.europa.ec.eudi.hiid.1', display: [steuerIdDisplay], + proof_types_supported: { + jwt: { + proof_signing_alg_values_supported: [JwaSignatureAlgorithm.ES256], + }, + }, } as const satisfies OpenId4VciCredentialConfigurationSupportedWithFormats export const steuerIdMdocData = { @@ -75,6 +80,11 @@ export const steuerIdSdJwt = { scope: 'steuer-id-sd-jwt', vct: 'https://example.eudi.ec.europa.eu/tax-credential/1', display: [steuerIdDisplay], + proof_types_supported: { + jwt: { + proof_signing_alg_values_supported: [JwaSignatureAlgorithm.ES256], + }, + }, } as const satisfies OpenId4VciCredentialConfigurationSupportedWithFormats export const steuerIdSdJwtData = { diff --git a/agent/src/issuers/techniker.ts b/agent/src/issuers/techniker.ts index 698c737..8ee3c84 100644 --- a/agent/src/issuers/techniker.ts +++ b/agent/src/issuers/techniker.ts @@ -48,6 +48,11 @@ export const healthIdMdoc = { scope: 'health-id-mdoc', doctype: 'eu.europa.ec.eudi.hiid.1', display: [healthIdDisplay], + proof_types_supported: { + jwt: { + proof_signing_alg_values_supported: [JwaSignatureAlgorithm.ES256], + }, + }, } as const satisfies OpenId4VciCredentialConfigurationSupportedWithFormats export const healthIdMdocData = { @@ -74,6 +79,11 @@ export const healthIdSdJwt = { scope: 'health-id-sd-jwt', vct: 'https://example.eudi.ec.europa.eu/hiid/1', display: [healthIdDisplay], + proof_types_supported: { + jwt: { + proof_signing_alg_values_supported: [JwaSignatureAlgorithm.ES256], + }, + }, } as const satisfies OpenId4VciCredentialConfigurationSupportedWithFormats export const healthIdSdJwtData = { diff --git a/agent/src/verifier.ts b/agent/src/verifier.ts index 039355d..10e2857 100644 --- a/agent/src/verifier.ts +++ b/agent/src/verifier.ts @@ -1,11 +1,12 @@ +import type { DcqlQuery, DifPresentationExchangeDefinitionV2 } from '@credo-ts/core' import type { OpenId4VcSiopCreateVerifierOptions } from '@credo-ts/openid4vc' import { agent } from './agent' -import type { DifPresentationExchangeDefinitionV2 } from '@credo-ts/core' export interface PlaygroundVerifierOptions { verifierId: string clientMetadata?: OpenId4VcSiopCreateVerifierOptions['clientMetadata'] presentationRequests: Array + dcqlRequests: Array } export async function createOrUpdateVerifier(options: PlaygroundVerifierOptions) { diff --git a/agent/src/verifiers/animo.ts b/agent/src/verifiers/animo.ts index 01b43d0..5c56f8a 100644 --- a/agent/src/verifiers/animo.ts +++ b/agent/src/verifiers/animo.ts @@ -1,6 +1,6 @@ -import { pidMdocInputDescriptor, pidSdJwtInputDescriptor } from './util' -import type { PlaygroundVerifierOptions } from '../verifier' import { AGENT_HOST } from '../constants' +import type { PlaygroundVerifierOptions } from '../verifier' +import { pidMdocInputDescriptor, pidSdJwtInputDescriptor } from './util' export const animoVerifier = { clientMetadata: { @@ -25,7 +25,7 @@ export const animoVerifier = { purpose: 'We need to verify your city', input_descriptors: [ pidSdJwtInputDescriptor({ - fields: ['place_of_birth.locality', 'adress.locality'], + fields: ['place_of_birth.locality', 'address.locality'], }), ], }, @@ -70,4 +70,5 @@ export const animoVerifier = { ], }, ], + dcqlRequests: [], } as const satisfies PlaygroundVerifierOptions diff --git a/agent/src/verifiers/index.ts b/agent/src/verifiers/index.ts index 88a0a9b..d95a9b4 100644 --- a/agent/src/verifiers/index.ts +++ b/agent/src/verifiers/index.ts @@ -1,6 +1,12 @@ -import type { DifPresentationExchangeDefinitionV2 } from '@credo-ts/core' +import type { PlaygroundVerifierOptions } from '../verifier' import { animoVerifier } from './animo' import { sixtVerifier } from './sixt' export const verifiers = [animoVerifier, sixtVerifier] -export const allDefinitions = verifiers.flatMap((v): DifPresentationExchangeDefinitionV2[] => v.presentationRequests) +export const allDefinitions = verifiers.flatMap( + ( + v + ): Array< + PlaygroundVerifierOptions['presentationRequests'][number] | PlaygroundVerifierOptions['dcqlRequests'][number] + > => [...v.presentationRequests, ...v.dcqlRequests] +) diff --git a/agent/src/verifiers/sixt.ts b/agent/src/verifiers/sixt.ts index 876843f..a6533ab 100644 --- a/agent/src/verifiers/sixt.ts +++ b/agent/src/verifiers/sixt.ts @@ -1,7 +1,15 @@ +import { AGENT_HOST } from '../constants' import { mobileDriversLicenseMdoc, mobileDriversLicenseSdJwt } from '../issuers/infrastruktur' -import { mdocInputDescriptor, pidSdJwtInputDescriptor, sdJwtInputDescriptor } from './util' import type { PlaygroundVerifierOptions } from '../verifier' -import { AGENT_HOST } from '../constants' +import { + mdocDcqlCredential, + mdocInputDescriptor, + pidMdocDcqlCredential, + pidSdJwtDcqlCredential, + pidSdJwtInputDescriptor, + sdJwtDcqlCredential, + sdJwtInputDescriptor, +} from './util' export const sixtVerifier = { verifierId: 'c01ea0f3-34df-41d5-89d1-50ef3d181855', @@ -39,7 +47,7 @@ export const sixtVerifier = { input_descriptors: [ mdocInputDescriptor({ doctype: mobileDriversLicenseMdoc.doctype, - namespace: mobileDriversLicenseMdoc.doctype, + namespace: 'org.iso.18013.5.1', fields: [ 'document_number', 'issue_date', @@ -55,4 +63,48 @@ export const sixtVerifier = { ], }, ], + dcqlRequests: [ + { + id: 'dc195d0e-114d-41d1-8803-e1ad08041dca', + name: 'PID and MDL - Rent a Car (vc+sd-jwt)', + credentials: [ + sdJwtDcqlCredential({ + vcts: [mobileDriversLicenseSdJwt.vct], + fields: [ + 'document_number', + 'portrait', + 'issue_date', + 'expiry_date', + 'issuing_country', + 'issuing_authority', + 'driving_priviliges', + ], + }), + pidSdJwtDcqlCredential({ + fields: ['given_name', 'family_name', 'birthdate'], + }), + ], + }, + { + id: 'a2a7aa98-5fff-4e6a-abb1-e8aa7c3adf9b', + name: 'PID and MDL - Rent a Car (vc+sd-jwt/mso_mdoc)', + credentials: [ + mdocDcqlCredential({ + doctype: mobileDriversLicenseMdoc.doctype, + namespace: 'org.iso.18013.5.1', + fields: [ + 'document_number', + 'issue_date', + 'expiry_date', + 'issuing_country', + 'issuing_authority', + 'driving_priviliges', + ], + }), + pidSdJwtDcqlCredential({ + fields: ['given_name', 'family_name', 'birthdate'], + }), + ], + }, + ], } as const satisfies PlaygroundVerifierOptions diff --git a/agent/src/verifiers/util.ts b/agent/src/verifiers/util.ts index e0b170b..fa1ee4a 100644 --- a/agent/src/verifiers/util.ts +++ b/agent/src/verifiers/util.ts @@ -1,16 +1,49 @@ import { randomUUID } from 'node:crypto' -import type { DifPresentationExchangeDefinitionV2 } from '@credo-ts/core' +import type { DcqlQuery, DifPresentationExchangeDefinitionV2 } from '@credo-ts/core' + +export function sdJwtDcqlCredential({ + vcts, + fields, + issuers, + id, +}: { + vcts: string[] + fields: string[] + issuers?: string[] + id?: string +}): DcqlQuery['credentials'][number] { + return { + id: id ?? randomUUID(), + format: 'vc+sd-jwt', + meta: { + vct_values: vcts, + }, + claims: [ + ...fields.map((field) => ({ + path: field.split('.'), + })), + issuers + ? { + path: ['iss'], + values: issuers, + } + : undefined, + ].filter((claim): claim is NonNullable => claim !== undefined), + } +} export function sdJwtInputDescriptor({ vcts, fields, issuers, id, + group, }: { vcts: string[] fields: string[] issuers?: string[] id?: string + group?: string | string[] }): DifPresentationExchangeDefinitionV2['input_descriptors'][number] { return { id: id ?? randomUUID(), @@ -20,6 +53,7 @@ export function sdJwtInputDescriptor({ 'kb-jwt_alg_values': ['ES256'], }, }, + group: group ? (Array.isArray(group) ? group : [group]) : undefined, constraints: { limit_disclosure: 'required', fields: [ @@ -47,14 +81,38 @@ export function sdJwtInputDescriptor({ } } +export function mdocDcqlCredential({ + doctype, + namespace, + fields, +}: { + doctype: string + namespace: string + fields: [string, ...string[]] +}): DcqlQuery['credentials'][number] { + return { + id: randomUUID(), + format: 'mso_mdoc', + meta: { + doctype_value: doctype, + }, + claims: fields.map((field) => ({ + namespace, + claim_name: field, + })), + } +} + export function mdocInputDescriptor({ doctype, namespace, fields, + group, }: { doctype: string namespace: string fields: string[] + group?: string | string[] }): DifPresentationExchangeDefinitionV2['input_descriptors'][number] { return { id: doctype, @@ -63,6 +121,7 @@ export function mdocInputDescriptor({ alg: ['ES256'], }, }, + group: group ? (Array.isArray(group) ? group : [group]) : undefined, constraints: { limit_disclosure: 'required', fields: [ @@ -75,16 +134,44 @@ export function mdocInputDescriptor({ } } -export function pidMdocInputDescriptor({ fields }: { fields: string[] }) { +export function pidMdocDcqlCredential({ fields }: { fields: [string, ...string[]] }) { + return mdocDcqlCredential({ + fields, + doctype: 'eu.europa.ec.eudi.pid.1', + namespace: 'eu.europa.ec.eudi.pid.1', + }) +} + +export function pidMdocInputDescriptor({ fields, group }: { fields: string[]; group?: string | string[] }) { return mdocInputDescriptor({ fields, + group, doctype: 'eu.europa.ec.eudi.pid.1', namespace: 'eu.europa.ec.eudi.pid.1', }) } -export function pidSdJwtInputDescriptor({ fields, id }: { fields: string[]; id?: string }) { + +export function pidSdJwtDcqlCredential({ fields, id }: { fields: [string, ...string[]]; id?: string }) { + return sdJwtDcqlCredential({ + id, + fields, + vcts: ['https://example.bmi.bund.de/credential/pid/1.0', 'urn:eu.europa.ec.eudi:pid:1'], + issuers: [ + 'https://demo.pid-issuer.bundesdruckerei.de/c', + 'https://demo.pid-issuer.bundesdruckerei.de/c1', + 'https://demo.pid-issuer.bundesdruckerei.de/b1', + ], + }) +} + +export function pidSdJwtInputDescriptor({ + fields, + id, + group, +}: { fields: string[]; id?: string; group?: string | string[] }) { return sdJwtInputDescriptor({ id, + group, fields, vcts: ['https://example.bmi.bund.de/credential/pid/1.0', 'urn:eu.europa.ec.eudi:pid:1'], issuers: [ diff --git a/app/components/CollapsibleSection.tsx b/app/components/CollapsibleSection.tsx new file mode 100644 index 0000000..2331a25 --- /dev/null +++ b/app/components/CollapsibleSection.tsx @@ -0,0 +1,30 @@ +import { Collapsible, CollapsibleContent, CollapsibleTrigger } from '@/components/ui/collapsible' +import { TypographyH4, TypographyP } from '@/components/ui/typography' +import { ChevronDown, ChevronRight } from 'lucide-react' +import React, { type PropsWithChildren } from 'react' + +export const CollapsibleSection = ({ + title, + titleSmall, + children, + initial = 'closed', +}: PropsWithChildren<{ title: string; initial?: 'open' | 'closed'; titleSmall?: string }>) => { + const [isOpen, setIsOpen] = React.useState(initial === 'open') + + return ( + + +
+ {isOpen ? : } + {title} + {titleSmall && isOpen && ( + <> + -

{titleSmall}

+ + )} +
+
+ {children} +
+ ) +} diff --git a/app/components/IssueTab.tsx b/app/components/IssueTab.tsx index d403e54..c8bb7c6 100644 --- a/app/components/IssueTab.tsx +++ b/app/components/IssueTab.tsx @@ -8,10 +8,10 @@ import Link from 'next/link' import { type FormEvent, useEffect, useState } from 'react' import QRCode from 'react-qr-code' import { createOffer, getIssuer, getX509Certificate } from '../lib/api' +import { X509Certificates } from './X509Certificates' import { Alert, AlertDescription, AlertTitle } from './ui/alert' export function IssueTab({ disabled = false }: { disabled?: boolean }) { - const [x509Certificate, setX509Certificate] = useState() const [credentialType, setCredentialType] = useState() const [issuerId, setIssuerid] = useState() const [credentialOfferUri, setCredentialOfferUri] = useState() @@ -26,8 +26,8 @@ export function IssueTab({ disabled = false }: { disabled?: boolean }) { useEffect(() => { getIssuer().then(setIssuer) - getX509Certificate().then(({ certificate }) => setX509Certificate(certificate)) }, []) + async function onSubmitIssueCredential(e: FormEvent) { e.preventDefault() const _issuerId = issuerId ?? issuer?.availableX509Certificates[0] @@ -107,6 +107,7 @@ export function IssueTab({ disabled = false }: { disabled?: boolean }) { + {/* biome-ignore lint/a11y/useKeyWithClickEvents: */}

navigator.clipboard.writeText(e.currentTarget.innerText)} className="text-gray-500 break-all cursor-pointer" @@ -156,27 +157,7 @@ export function IssueTab({ disabled = false }: { disabled?: boolean }) { > Issue Credential -

-

X.509 Certificate in base64 format

- - -
- -

navigator.clipboard.writeText(e.currentTarget.innerText)} - className="text-gray-500 break-all cursor-pointer" - > - {x509Certificate ?? 'No X.509 Certificate found'} -

-
-
- - -

Click to copy

-
-
-
-
+ ) diff --git a/app/components/VerifyBlock.tsx b/app/components/VerifyBlock.tsx index 1f1b883..6253582 100644 --- a/app/components/VerifyBlock.tsx +++ b/app/components/VerifyBlock.tsx @@ -5,6 +5,8 @@ import { Tooltip, TooltipContent, TooltipProvider, TooltipTrigger } from '@radix import Link from 'next/link' import { type FormEvent, useEffect, useState } from 'react' import QRCode from 'react-qr-code' +import { CollapsibleSection } from './CollapsibleSection' +import { X509Certificates } from './X509Certificates' import { HighLight } from './highLight' import { Alert, AlertDescription, AlertTitle } from './ui/alert' import { Button } from './ui/button' @@ -12,13 +14,13 @@ import { Card } from './ui/card' import { Input } from './ui/input' import { Label } from './ui/label' import { Select, SelectContent, SelectGroup, SelectItem, SelectTrigger, SelectValue } from './ui/select' -import { TypographyH3, TypographyH4 } from './ui/typography' +import { TypographyH3 } from './ui/typography' export type ResponseMode = 'direct_post' | 'direct_post.jwt' +type ResponseStatus = 'RequestCreated' | 'RequestUriRetrieved' | 'ResponseVerified' | 'Error' type VerifyBlockProps = { flowName: string - x509Certificate?: string createRequest: ({ presentationDefinitionId, requestScheme, @@ -30,18 +32,23 @@ type VerifyBlockProps = { }) => Promise<{ verificationSessionId: string authorizationRequestUri: string + authorizationRequest: Record + responseStatus: ResponseStatus }> } -export const VerifyBlock: React.FC = ({ createRequest, flowName, x509Certificate }) => { +export const VerifyBlock: React.FC = ({ createRequest, flowName }) => { const [authorizationRequestUri, setAuthorizationRequestUri] = useState() const [verificationSessionId, setVerificationSessionId] = useState() const [requestStatus, setRequestStatus] = useState<{ verificationSessionId: string - responseStatus: 'RequestCreated' | 'RequestUriRetrieved' | 'ResponseVerified' | 'Error' + responseStatus: ResponseStatus + authorizationRequest: Record error?: string submission?: Record definition?: Record + dcqlQuery?: Record + dcqlSubmission?: Record presentations?: Array> }>() const [verifier, setVerifier] = useState<{ @@ -91,7 +98,7 @@ export const VerifyBlock: React.FC = ({ createRequest, flowNam throw new Error('No definition') } const request = await createRequest({ presentationDefinitionId: id, requestScheme, responseMode }) - + setRequestStatus(request) setVerificationSessionId(request.verificationSessionId) setAuthorizationRequestUri(request.authorizationRequestUri) } @@ -116,7 +123,7 @@ export const VerifyBlock: React.FC = ({ createRequest, flowNam {flowName}
- +