From 4e25bc844547d7a562a957991050071b1e900e9a Mon Sep 17 00:00:00 2001 From: Martin Auer Date: Fri, 27 Sep 2024 19:00:47 +0200 Subject: [PATCH] feat: add jarm support node-only --- .../OpenId4vcSiopHolderService.ts | 69 ++++++++-- .../OpenId4VcSiopVerifierService.ts | 79 ++++++++--- .../OpenId4VcSiopVerifierServiceOptions.ts | 11 ++ .../__tests__/openid4vc-verifier.test.ts | 1 - .../router/authorizationEndpoint.ts | 124 +++++++++++++++--- .../openid4vc/tests/openid4vc.e2e.test.ts | 5 +- 6 files changed, 242 insertions(+), 47 deletions(-) diff --git a/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts b/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts index abc9038b03..0310e21a39 100644 --- a/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts +++ b/packages/openid4vc/src/openid4vc-holder/OpenId4vcSiopHolderService.ts @@ -4,24 +4,49 @@ import type { } from './OpenId4vcSiopHolderServiceOptions' import type { OpenId4VcJwtIssuer } from '../shared' import type { AgentContext, VerifiablePresentation } from '@credo-ts/core' -import type { VerifiedAuthorizationRequest, PresentationExchangeResponseOpts } from '@sphereon/did-auth-siop' +import type { JoseJweEncryptJwt } from '@protokoll/jose' +import type { + AuthorizationResponsePayload, + PresentationExchangeResponseOpts, + RequestObjectPayload, + VerifiedAuthorizationRequest, +} from '@sphereon/did-auth-siop' import { - Hasher, - W3cJwtVerifiablePresentation, - parseDid, CredoError, - injectable, - W3cJsonLdVerifiablePresentation, - asArray, DifPresentationExchangeService, DifPresentationExchangeSubmissionLocation, + Hasher, + W3cJsonLdVerifiablePresentation, + W3cJwtVerifiablePresentation, + asArray, + injectable, + parseDid, } from '@credo-ts/core' import { OP, ResponseIss, ResponseMode, ResponseType, SupportedVersion, VPTokenLocation } from '@sphereon/did-auth-siop' +import * as jose from 'jose' import { getSphereonVerifiablePresentation } from '../shared/transform' import { getCreateJwtCallback, getVerifyJwtCallback, openIdTokenIssuerToJwtIssuer } from '../shared/utils' +const encryptJwt: JoseJweEncryptJwt = async (input) => { + const { payload, protectedHeader, jwk, alg, keyManagementParameters } = input + const encode = TextEncoder.prototype.encode.bind(new TextEncoder()) + const recipientPublicKey = await jose.importJWK(jwk, alg) + + const joseEncryptJwt = new jose.EncryptJWT(payload).setProtectedHeader(protectedHeader) + + if (keyManagementParameters) { + joseEncryptJwt.setKeyManagementParameters({ + apu: encode(keyManagementParameters.apu), + apv: encode(keyManagementParameters.apv), + }) + } + + const jwe = await joseEncryptJwt.encrypt(recipientPublicKey) + return { jwe } +} + @injectable() export class OpenId4VcSiopHolderService { public constructor(private presentationExchangeService: DifPresentationExchangeService) {} @@ -143,7 +168,35 @@ export class OpenId4VcSiopHolderService { } ) - const response = await openidProvider.submitAuthorizationResponse(authorizationResponseWithCorrelationId) + const createJarmResponse = async (opts: { + authorizationResponsePayload: AuthorizationResponsePayload + requestObjectPayload: RequestObjectPayload + }) => { + const { authorizationResponsePayload, requestObjectPayload } = opts + + const jwk = await OP.extractEncJwksFromClientMetadata(requestObjectPayload.client_metadata) + if (!jwk.alg) { + throw new CredoError( + 'Missing alg in jwk. Cannot determine encryption algorithm, for creating the JARM response.' + ) + } + const { jwe } = await encryptJwt({ + jwk, + payload: authorizationResponsePayload, + protectedHeader: { + alg: jwk.alg, + enc: 'A256GCM', // TODO: + kid: jwk.kid, + }, + }) + + return { response: jwe } + } + + const response = await openidProvider.submitAuthorizationResponse( + authorizationResponseWithCorrelationId, + createJarmResponse + ) let responseDetails: string | Record | undefined = undefined try { responseDetails = await response.text() diff --git a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts index bc8043b1ea..38e77811d4 100644 --- a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts +++ b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts @@ -4,6 +4,7 @@ import type { OpenId4VcSiopCreateVerifierOptions, OpenId4VcSiopVerifiedAuthorizationResponse, OpenId4VcSiopVerifyAuthorizationResponseOptions, + ResponseMode, } from './OpenId4VcSiopVerifierServiceOptions' import type { OpenId4VcVerificationSessionRecord } from './repository' import type { OpenId4VcSiopAuthorizationResponsePayload } from '../shared' @@ -36,6 +37,7 @@ import { DidsApi, X509Service, getDomainFromUrl, + KeyType, } from '@credo-ts/core' import { AuthorizationRequest, @@ -44,7 +46,7 @@ import { PropertyTarget, RequestAud, ResponseIss, - ResponseMode, + ResponseMode as SphereonResponseMode, ResponseType, RevocationVerification, RP, @@ -72,6 +74,16 @@ import { import { OpenId4VcRelyingPartyEventHandler } from './repository/OpenId4VcRelyingPartyEventEmitter' import { OpenId4VcRelyingPartySessionManager } from './repository/OpenId4VcRelyingPartySessionManager' +export const ISO_MDL_7_EPHEMERAL_READER_PUBLIC_KEY_JWK = { + kty: 'EC', + use: 'enc', + crv: 'P-256', + x: 'xVLtZaPPK-xvruh1fEClNVTR6RCZBsQai2-DrnyKkxg', + y: '-5-QtFqJqGwOjEL3Ut89nrE0MeaUp5RozksKHpBiyw0', + alg: 'ECDH-ES', + kid: 'P8p0virRlh6fAkh5-YSeHt4EIv-hFGneYk14d8DF51w', +} + /** * @internal */ @@ -146,6 +158,7 @@ export class OpenId4VcSiopVerifierService { authorizationResponseUrl, clientId, clientIdScheme, + responseMode: options.responseMode, }) // We always use shortened URIs currently @@ -352,26 +365,45 @@ export class OpenId4VcSiopVerifierService { agentContext: AgentContext, { authorizationResponse, + authorizationResponseParams, verifierId, - }: { - authorizationResponse: OpenId4VcSiopAuthorizationResponsePayload - verifierId?: string - } + }: + | { + authorizationResponse?: never + authorizationResponseParams: { + state?: string + nonce?: string + } + verifierId?: string + } + | { + authorizationResponse: OpenId4VcSiopAuthorizationResponsePayload + authorizationResponseParams?: never + verifierId?: string + } ) { - const authorizationResponseInstance = await AuthorizationResponse.fromPayload(authorizationResponse).catch(() => { - throw new CredoError(`Unable to parse authorization response payload. ${JSON.stringify(authorizationResponse)}`) - }) + let nonce: string | undefined + let state: string | undefined - const responseNonce = await authorizationResponseInstance.getMergedProperty('nonce', { - hasher: Hasher.hash, - }) - const responseState = await authorizationResponseInstance.getMergedProperty('state', { - hasher: Hasher.hash, - }) + if (authorizationResponse) { + const authorizationResponseInstance = await AuthorizationResponse.fromPayload(authorizationResponse).catch(() => { + throw new CredoError(`Unable to parse authorization response payload. ${JSON.stringify(authorizationResponse)}`) + }) + + nonce = await authorizationResponseInstance.getMergedProperty('nonce', { + hasher: Hasher.hash, + }) + state = await authorizationResponseInstance.getMergedProperty('state', { + hasher: Hasher.hash, + }) + } else { + nonce = authorizationResponseParams.nonce + state = authorizationResponse + } const verificationSession = await this.openId4VcVerificationSessionRepository.findSingleByQuery(agentContext, { - nonce: responseNonce, - payloadState: responseState, + nonce, + payloadState: state, verifierId, }) @@ -421,7 +453,9 @@ export class OpenId4VcSiopVerifierService { clientId, clientIdScheme, authorizationResponseUrl, + responseMode, }: { + responseMode?: ResponseMode authorizationResponseUrl: string idToken?: boolean presentationDefinition?: DifPresentationExchangeDefinition @@ -459,6 +493,16 @@ export class OpenId4VcSiopVerifierService { .resolve(OpenId4VcRelyingPartyEventHandler) .getEventEmitterForVerifier(agentContext.contextCorrelationId, verifierId) + const mode = + !responseMode || responseMode === 'direct_post' + ? SphereonResponseMode.DIRECT_POST + : SphereonResponseMode.DIRECT_POST_JWT + + const jarmKey = + mode === SphereonResponseMode.DIRECT_POST_JWT + ? await agentContext.wallet.createKey({ keyType: KeyType.P256 }) + : undefined + builder .withResponseUri(authorizationResponseUrl) .withIssuer(ResponseIss.SELF_ISSUED_V2) @@ -469,7 +513,7 @@ export class OpenId4VcSiopVerifierService { SupportedVersion.SIOPv2_D12_OID4VP_D18, SupportedVersion.SIOPv2_D12_OID4VP_D20, ]) - .withResponseMode(ResponseMode.DIRECT_POST) + .withResponseMode(mode) .withHasher(Hasher.hash) // FIXME: should allow verification of revocation // .withRevocationVerificationCallback() @@ -482,6 +526,7 @@ export class OpenId4VcSiopVerifierService { // TODO: we should probably allow some dynamic values here .withClientMetadata({ + jwks: jarmKey ? { keys: [ISO_MDL_7_EPHEMERAL_READER_PUBLIC_KEY_JWK] } : undefined, client_id: clientId, client_id_scheme: clientIdScheme, passBy: PassBy.VALUE, diff --git a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierServiceOptions.ts b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierServiceOptions.ts index bba0d44663..a29ccced72 100644 --- a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierServiceOptions.ts +++ b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierServiceOptions.ts @@ -12,6 +12,8 @@ import type { VerifiablePresentation, } from '@credo-ts/core' +export type ResponseMode = 'direct_post' | 'direct_post.jwt' + export interface OpenId4VcSiopCreateAuthorizationRequestOptions { /** * Signing information for the request JWT. This will be used to sign the request JWT @@ -35,6 +37,15 @@ export interface OpenId4VcSiopCreateAuthorizationRequestOptions { presentationExchange?: { definition: DifPresentationExchangeDefinitionV2 } + + /** + * The response mode to use for the authorization request. + * @default to `direct_post`. + * + * With response_mode `direct_post` the response will be posted directly to the `response_uri` provided in the request. + * With response_mode `direct_post.jwt` the response will be `signed` `encrypted` or `signed and encrypted` and then posted to the `response_uri` provided in the request. + */ + responseMode?: ResponseMode } export interface OpenId4VcSiopVerifyAuthorizationResponseOptions { diff --git a/packages/openid4vc/src/openid4vc-verifier/__tests__/openid4vc-verifier.test.ts b/packages/openid4vc/src/openid4vc-verifier/__tests__/openid4vc-verifier.test.ts index 8ae824b8f6..d1d1662649 100644 --- a/packages/openid4vc/src/openid4vc-verifier/__tests__/openid4vc-verifier.test.ts +++ b/packages/openid4vc/src/openid4vc-verifier/__tests__/openid4vc-verifier.test.ts @@ -62,7 +62,6 @@ describe('OpenId4VcVerifier', () => { expect(jwt.header.kid).toEqual(verifier.kid) expect(jwt.header.alg).toEqual(SigningAlgo.EDDSA) expect(jwt.header.typ).toEqual('JWT') - expect(jwt.payload.additionalClaims.scope).toEqual('openid') expect(jwt.payload.additionalClaims.client_id).toEqual(verifier.did) expect(jwt.payload.additionalClaims.response_uri).toEqual( `http://redirect-uri/${openIdVerifier.verifierId}/authorize` diff --git a/packages/openid4vc/src/openid4vc-verifier/router/authorizationEndpoint.ts b/packages/openid4vc/src/openid4vc-verifier/router/authorizationEndpoint.ts index b83c7374b4..0bcea2dbd2 100644 --- a/packages/openid4vc/src/openid4vc-verifier/router/authorizationEndpoint.ts +++ b/packages/openid4vc/src/openid4vc-verifier/router/authorizationEndpoint.ts @@ -1,10 +1,50 @@ import type { OpenId4VcVerificationRequest } from './requestContext' +import type { OpenId4VcVerificationSessionRecord } from '../repository' +import type { JwkJson } from '@credo-ts/core' +import type { AgentContext } from '@credo-ts/core/src/agent/context/AgentContext' import type { AuthorizationResponsePayload } from '@sphereon/did-auth-siop' -import type { Router, Response } from 'express' +import type { Response, Router } from 'express' + +import { CredoError } from '@credo-ts/core' +import { AuthorizationRequest, RP } from '@sphereon/did-auth-siop' +import * as jose from 'jose' import { getRequestContext, sendErrorResponse } from '../../shared/router' import { OpenId4VcSiopVerifierService } from '../OpenId4VcSiopVerifierService' +export const ISO_MDL_7_EPHEMERAL_READER_PRIVATE_KEY_JWK = { + kty: 'EC', + d: '_Hc7lRd1Zt8sDAb1-pCgI9qS3oobKNa-mjRDhaKjH90', + use: 'enc', + crv: 'P-256', + x: 'xVLtZaPPK-xvruh1fEClNVTR6RCZBsQai2-DrnyKkxg', + y: '-5-QtFqJqGwOjEL3Ut89nrE0MeaUp5RozksKHpBiyw0', + alg: 'ECDH-ES', + kid: 'P8p0virRlh6fAkh5-YSeHt4EIv-hFGneYk14d8DF51w', +} + +const decryptCompact = async (input: { jwk: { kid: string }; jwe: string }) => { + const { jwe, jwk } = input + + let jwkToUse: JwkJson + if (jwk.kid === ISO_MDL_7_EPHEMERAL_READER_PRIVATE_KEY_JWK.kid) { + jwkToUse = ISO_MDL_7_EPHEMERAL_READER_PRIVATE_KEY_JWK + } else { + throw new CredoError('Invalid JWK provided for decryption') + } + + const privateKey = await jose.importJWK(jwkToUse) + const decode = TextDecoder.prototype.decode.bind(new TextDecoder()) + + const { plaintext, protectedHeader } = await jose.compactDecrypt(jwe, privateKey) + + return { + plaintext: decode(plaintext), + // eslint-disable-next-line @typescript-eslint/no-explicit-any + protectedHeader: protectedHeader as any, + } +} + export interface OpenId4VcSiopAuthorizationEndpointConfig { /** * The path at which the authorization endpoint should be made available. Note that it will be @@ -15,34 +55,80 @@ export interface OpenId4VcSiopAuthorizationEndpointConfig { endpointPath: string } +async function getVerificationSession( + agentContext: AgentContext, + options: { + verifierId: string + state?: string + nonce?: unknown + } +): Promise { + const { verifierId, state, nonce } = options + + const openId4VcVerifierService = agentContext.dependencyManager.resolve(OpenId4VcSiopVerifierService) + const session = await openId4VcVerifierService.findVerificationSessionForAuthorizationResponse(agentContext, { + authorizationResponseParams: { state, nonce: nonce as string }, + verifierId, + }) + + if (!session) { + agentContext.config.logger.warn( + `No verification session found for incoming authorization response for verifier ${verifierId}` + ) + throw new CredoError(`No state or nonce provided in authorization response for verifier ${verifierId}`) + } + + return session +} + export function configureAuthorizationEndpoint(router: Router, config: OpenId4VcSiopAuthorizationEndpointConfig) { router.post(config.endpointPath, async (request: OpenId4VcVerificationRequest, response: Response, next) => { const { agentContext, verifier } = getRequestContext(request) try { const openId4VcVerifierService = agentContext.dependencyManager.resolve(OpenId4VcSiopVerifierService) - const isVpRequest = request.body.presentation_submission !== undefined - - const authorizationResponse: AuthorizationResponsePayload = request.body - if (isVpRequest) authorizationResponse.presentation_submission = JSON.parse(request.body.presentation_submission) - - const verificationSession = await openId4VcVerifierService.findVerificationSessionForAuthorizationResponse( - agentContext, - { - authorizationResponse, - verifierId: verifier.verifierId, - } - ) - - if (!verificationSession) { - agentContext.config.logger.warn( - `No verification session found for incoming authorization response for verifier ${verifier.verifierId}` + + let verificationSession: OpenId4VcVerificationSessionRecord + let authorizationResponsePayload: AuthorizationResponsePayload + + if (request.body.response) { + const res = await RP.processJarmAuthorizationResponse(request.body.response, { + getAuthRequestPayload: async (input) => { + verificationSession = await getVerificationSession(agentContext, { + verifierId: verifier.verifierId, + state: input.state, + nonce: input.nonce, + }) + + const res = await AuthorizationRequest.fromUriOrJwt(verificationSession.authorizationRequestJwt) + const requestObjectPayload = await res.requestObject?.getPayload() + if (!requestObjectPayload) { + throw new CredoError('No request object payload found.') + } + return { authRequestParams: requestObjectPayload } + }, + decryptCompact, + }) + + authorizationResponsePayload = res.authResponseParams as AuthorizationResponsePayload + } else { + authorizationResponsePayload = request.body + } + + verificationSession = await getVerificationSession(agentContext, { + verifierId: verifier.verifierId, + state: authorizationResponsePayload.state, + nonce: authorizationResponsePayload.nonce, + }) + + if (typeof authorizationResponsePayload.presentation_submission === 'string') { + authorizationResponsePayload.presentation_submission = JSON.parse( + authorizationResponsePayload.presentation_submission ) - return sendErrorResponse(response, agentContext.config.logger, 404, 'invalid_request', null) } await openId4VcVerifierService.verifyAuthorizationResponse(agentContext, { - authorizationResponse: request.body, + authorizationResponse: authorizationResponsePayload, verificationSession, }) response.status(200).send() diff --git a/packages/openid4vc/tests/openid4vc.e2e.test.ts b/packages/openid4vc/tests/openid4vc.e2e.test.ts index 83279e4611..e8e8d6ebcc 100644 --- a/packages/openid4vc/tests/openid4vc.e2e.test.ts +++ b/packages/openid4vc/tests/openid4vc.e2e.test.ts @@ -708,7 +708,7 @@ describe('OpenId4Vc', () => { }) }) - it('e2e flow with verifier endpoints verifying a sd-jwt-vc with selective disclosure', async () => { + it('e2e flow (jarm) with verifier endpoints verifying a sd-jwt-vc with selective disclosure', async () => { const openIdVerifier = await verifier.agent.modules.openId4VcVerifier.createVerifier() const signedSdJwtVc = await issuer.agent.sdJwtVc.sign({ @@ -771,7 +771,7 @@ describe('OpenId4Vc', () => { const { authorizationRequest, verificationSession } = await verifier.agent.modules.openId4VcVerifier.createAuthorizationRequest({ verifierId: openIdVerifier.verifierId, - + responseMode: 'direct_post.jwt', requestSigner: { method: 'x5c', x5c: [rawCertificate], @@ -791,6 +791,7 @@ describe('OpenId4Vc', () => { const resolvedAuthorizationRequest = await holder.agent.modules.openId4VcHolder.resolveSiopAuthorizationRequest( authorizationRequest ) + expect(resolvedAuthorizationRequest.authorizationRequest.payload?.response_mode).toEqual('direct_post.jwt') expect(resolvedAuthorizationRequest.presentationExchange?.credentialsForRequest).toEqual({ areRequirementsSatisfied: true,