Skip to content

Commit

Permalink
fix: validate client_metadata
Browse files Browse the repository at this point in the history
Signed-off-by: Martin Auer <[email protected]>
  • Loading branch information
auer-martin committed Sep 29, 2024
1 parent 6ddbee0 commit ee45087
Show file tree
Hide file tree
Showing 5 changed files with 275 additions and 15 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -160,9 +160,24 @@ export class OpenId4VcSiopHolderService {
if (!jwk.kty) {
throw new CredoError('Missing kty in jwk.')
}

const validatedMetadata = OP.validateJarmMetadata({
client_metadata: requestObjectPayload.client_metadata,
server_metadata: {
authorization_encryption_alg_values_supported: ['ECDH-ES'],
authorization_encryption_enc_values_supported: ['A256GCM'],
},
})

if (validatedMetadata.type !== 'encrypted') {
throw new CredoError('Only encrypted JARM responses are supported.')
}

const jwe = await this.encryptJarmResponse(agentContext, {
jwkJson: { ...jwk, kty: jwk.kty },
payload: authorizationResponsePayload,
encryptionAlgorithm: validatedMetadata.client_metadata.authorization_encrypted_response_alg,
enc: validatedMetadata.client_metadata.authorization_encrypted_response_enc,
})

return { response: jwe }
Expand Down Expand Up @@ -308,7 +323,7 @@ export class OpenId4VcSiopHolderService {

private async encryptJarmResponse(
agentContext: AgentContext,
options: { jwkJson: JwkJson; payload: Record<string, unknown> }
options: { jwkJson: JwkJson; payload: Record<string, unknown>; encryptionAlgorithm: string; enc: string }
) {
const { payload, jwkJson } = options
const jwk = getJwkFromJson(jwkJson)
Expand Down
6 changes: 4 additions & 2 deletions packages/openid4vc/src/openid4vc-holder/__tests__/fixtures.ts
Original file line number Diff line number Diff line change
Expand Up @@ -127,7 +127,7 @@ export const matrrLaunchpadDraft11JwtVcJson = {

export const waltIdDraft11JwtVcJson = {
credentialOffer:
'openid-credential-offer://?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Fissuer.portal.walt.id%22%2C%22credentials%22%3A%5B%22UniversityDegree%22%5D%2C%22grants%22%3A%7B%22authorization_code%22%3A%7B%22issuer_state%22%3A%22efc2f5dd-0f44-4f38-a902-3204e732c391%22%7D%2C%22urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Apre-authorized_code%22%3A%7B%22pre-authorized_code%22%3A%22eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJlZmMyZjVkZC0wZjQ0LTRmMzgtYTkwMi0zMjA0ZTczMmMzOTEiLCJpc3MiOiJodHRwczovL2lzc3Vlci5wb3J0YWwud2FsdC5pZCIsImF1ZCI6IlRPS0VOIn0.OHzYTP_u6I95hHBmjF3RchydGidq3nsT0QHdgJ1AXyR5AFkrTfJwsW4FQIdOdda93uS7FOh_vSVGY0Qngzm7Ag%22%2C%22user_pin_required%22%3Afalse%7D%7D%7D',
'openid-credential-offer://?credential_offer=%7B%22credential_issuer%22%3A%22https%3A%2F%2Fissuer.portal.walt.id%22%2C%22credentials%22%3A%5B%22UniversityDegree%22%5D%2C%22grants%22%3A%7B%22urn%3Aietf%3Aparams%3Aoauth%3Agrant-type%3Apre-authorized_code%22%3A%7B%22pre-authorized_code%22%3A%22eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJlZmMyZjVkZC0wZjQ0LTRmMzgtYTkwMi0zMjA0ZTczMmMzOTEiLCJpc3MiOiJodHRwczovL2lzc3Vlci5wb3J0YWwud2FsdC5pZCIsImF1ZCI6IlRPS0VOIn0.OHzYTP_u6I95hHBmjF3RchydGidq3nsT0QHdgJ1AXyR5AFkrTfJwsW4FQIdOdda93uS7FOh_vSVGY0Qngzm7Ag%22%2C%22user_pin_required%22%3Afalse%7D%7D%7D',
getMetadataResponse: {
issuer: 'https://issuer.portal.walt.id',
authorization_endpoint: 'https://issuer.portal.walt.id/authorize',
Expand Down Expand Up @@ -235,7 +235,9 @@ export const waltIdDraft11JwtVcJson = {
'eyJhbGciOiJFZERTQSJ9.eyJzdWIiOiJjMDQyMmUxMy1kNTU0LTQwMmUtOTQ0OS0yZjA0ZjAyNjMzNTMiLCJpc3MiOiJodHRwczovL2lzc3Vlci5wb3J0YWwud2FsdC5pZCIsImF1ZCI6IkFDQ0VTUyJ9.pkNF05uUy72QAoZwdf1Uz1XRc4aGs1hhnim-x1qIeMe17TMUYV2D6BOATQtDItxnnhQz2MBfqUSQKYi7CFirDA',
token_type: 'bearer',
c_nonce: 'd4364dac-f026-4380-a4c3-2bfe2d2df52a',
c_nonce_expires_in: 27,
c_nonce_expires_in: 300000,
expires_in: 180000,
authorization_pending: false,
},

authorizationCode:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@ import type {
RecordSavedEvent,
RecordUpdatedEvent,
} from '@credo-ts/core'
import type { ClientIdScheme, PresentationVerificationCallback } from '@sphereon/did-auth-siop'
import type { ClientIdScheme, JarmClientMetadata, PresentationVerificationCallback } from '@sphereon/did-auth-siop'

import {
EventEmitter,
Expand Down Expand Up @@ -490,13 +490,23 @@ export class OpenId4VcSiopVerifierService {
? SphereonResponseMode.DIRECT_POST
: SphereonResponseMode.DIRECT_POST_JWT

let jarmEncryptionJwk: (JwkJson & { kid: string; use: 'enc' }) | undefined
type JarmEncryptionJwk = JwkJson & { kid: string; use: 'enc' }
let jarmEncryptionJwk: JarmEncryptionJwk | undefined

if (mode === SphereonResponseMode.DIRECT_POST_JWT) {
const key = await agentContext.wallet.createKey({ keyType: KeyType.P256 })
jarmEncryptionJwk = { ...getJwkFromKey(key).toJson(), kid: key.fingerprint, use: 'enc' }
}

const jarmClientMetadata: (JarmClientMetadata & { jwks: { keys: JarmEncryptionJwk[] } }) | undefined =
jarmEncryptionJwk
? {
jwks: { keys: [jarmEncryptionJwk] },
authorization_encrypted_response_alg: 'ECDH-ES',
authorization_encrypted_response_enc: 'A256GCM',
}
: undefined

builder
.withResponseUri(authorizationResponseUrl)
.withIssuer(ResponseIss.SELF_ISSUED_V2)
Expand All @@ -520,8 +530,8 @@ export class OpenId4VcSiopVerifierService {

// TODO: we should probably allow some dynamic values here
.withClientMetadata({
jwks: jarmEncryptionJwk ? { keys: [jarmEncryptionJwk] } : undefined,
client_id: clientId,
...jarmClientMetadata,
client_id_scheme: clientIdScheme,
passBy: PassBy.VALUE,
responseTypesSupported: [ResponseType.VP_TOKEN],
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,7 @@ export function configureAuthorizationEndpoint(router: Router, config: OpenId4Vc
try {
const openId4VcVerifierService = agentContext.dependencyManager.resolve(OpenId4VcSiopVerifierService)

let verificationSession: OpenId4VcVerificationSessionRecord
let verificationSession: OpenId4VcVerificationSessionRecord | undefined
let authorizationResponsePayload: AuthorizationResponsePayload

if (request.body.response) {
Expand All @@ -83,8 +83,8 @@ export function configureAuthorizationEndpoint(router: Router, config: OpenId4Vc
nonce: input.nonce,
})

const res = await AuthorizationRequest.fromUriOrJwt(verificationSession.authorizationRequestJwt)
const requestObjectPayload = await res.requestObject?.getPayload()
const req = await AuthorizationRequest.fromUriOrJwt(verificationSession.authorizationRequestJwt)
const requestObjectPayload = await req.requestObject?.getPayload()
if (!requestObjectPayload) {
throw new CredoError('No request object payload found.')
}
Expand All @@ -96,20 +96,22 @@ export function configureAuthorizationEndpoint(router: Router, config: OpenId4Vc
authorizationResponsePayload = res.authResponseParams as AuthorizationResponsePayload
} else {
authorizationResponsePayload = request.body
verificationSession = await getVerificationSession(agentContext, {
verifierId: verifier.verifierId,
state: authorizationResponsePayload.state,
nonce: authorizationResponsePayload.nonce,
})
}

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
)
}

if (!verificationSession) {
throw new CredoError('Missing verification session, cannot verify authorization response.')
}
await openId4VcVerifierService.verifyAuthorizationResponse(agentContext, {
authorizationResponse: authorizationResponsePayload,
verificationSession,
Expand Down
231 changes: 231 additions & 0 deletions packages/openid4vc/tests/openid4vc.e2e.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -940,4 +940,235 @@ describe('OpenId4Vc', () => {
],
})
})

it('e2e flow 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({
holder: { method: 'did', didUrl: holder.kid },
issuer: {
method: 'did',
didUrl: issuer.kid,
},
payload: {
vct: 'OpenBadgeCredential',
university: 'innsbruck',
degree: 'bachelor',
name: 'John Doe',
},
disclosureFrame: {
_sd: ['university', 'name'],
},
})

const certificate = await verifier.agent.x509.createSelfSignedCertificate({
key: await verifier.agent.wallet.createKey({ keyType: KeyType.Ed25519 }),
extensions: [[{ type: 'dns', value: 'localhost:1234' }]],
})

const rawCertificate = certificate.toString('base64')
await holder.agent.sdJwtVc.store(signedSdJwtVc.compact)

await holder.agent.x509.addTrustedCertificate(rawCertificate)
await verifier.agent.x509.addTrustedCertificate(rawCertificate)

const presentationDefinition = {
id: 'OpenBadgeCredential',
input_descriptors: [
{
id: 'OpenBadgeCredentialDescriptor',
format: {
'vc+sd-jwt': {
'sd-jwt_alg_values': ['EdDSA'],
},
},
constraints: {
limit_disclosure: 'required',
fields: [
{
path: ['$.vct'],
filter: {
type: 'string',
const: 'OpenBadgeCredential',
},
},
{
path: ['$.university'],
},
],
},
},
],
} satisfies DifPresentationExchangeDefinitionV2

const { authorizationRequest, verificationSession } =
await verifier.agent.modules.openId4VcVerifier.createAuthorizationRequest({
verifierId: openIdVerifier.verifierId,
requestSigner: {
method: 'x5c',
x5c: [rawCertificate],
issuer: 'https://example.com/hakuna/matadata',
},
presentationExchange: {
definition: presentationDefinition,
},
})

expect(authorizationRequest).toEqual(
`openid4vp://?client_id=localhost%3A1234&request_uri=${encodeURIComponent(
verificationSession.authorizationRequestUri
)}`
)

const resolvedAuthorizationRequest = await holder.agent.modules.openId4VcHolder.resolveSiopAuthorizationRequest(
authorizationRequest
)

expect(resolvedAuthorizationRequest.presentationExchange?.credentialsForRequest).toEqual({
areRequirementsSatisfied: true,
name: undefined,
purpose: undefined,
requirements: [
{
isRequirementSatisfied: true,
needsCount: 1,
rule: 'pick',
submissionEntry: [
{
name: undefined,
purpose: undefined,
inputDescriptorId: 'OpenBadgeCredentialDescriptor',
verifiableCredentials: [
{
type: ClaimFormat.SdJwtVc,
credentialRecord: expect.objectContaining({
compactSdJwtVc: signedSdJwtVc.compact,
}),
// Name is NOT in here
disclosedPayload: {
cnf: {
kid: 'did:key:z6MkpGR4gs4Rc3Zph4vj8wRnjnAxgAPSxcR8MAVKutWspQzc#z6MkpGR4gs4Rc3Zph4vj8wRnjnAxgAPSxcR8MAVKutWspQzc',
},
degree: 'bachelor',
iat: expect.any(Number),
iss: 'did:key:z6MkrzQPBr4pyqC776KKtrz13SchM5ePPbssuPuQZb5t4uKQ',
university: 'innsbruck',
vct: 'OpenBadgeCredential',
},
},
],
},
],
},
],
})

if (!resolvedAuthorizationRequest.presentationExchange) {
throw new Error('Presentation exchange not defined')
}

// TODO: better way to auto-select
const presentationExchangeService = holder.agent.dependencyManager.resolve(DifPresentationExchangeService)
const selectedCredentials = presentationExchangeService.selectCredentialsForRequest(
resolvedAuthorizationRequest.presentationExchange.credentialsForRequest
)

const { serverResponse, submittedResponse } =
await holder.agent.modules.openId4VcHolder.acceptSiopAuthorizationRequest({
authorizationRequest: resolvedAuthorizationRequest.authorizationRequest,
presentationExchange: {
credentials: selectedCredentials,
},
})

// path_nested should not be used for sd-jwt
expect(submittedResponse.presentation_submission?.descriptor_map[0].path_nested).toBeUndefined()
expect(submittedResponse).toEqual({
presentation_submission: {
definition_id: 'OpenBadgeCredential',
descriptor_map: [
{
format: 'vc+sd-jwt',
id: 'OpenBadgeCredentialDescriptor',
path: '$',
},
],
id: expect.any(String),
},
state: expect.any(String),
vp_token: expect.any(String),
})
expect(serverResponse).toMatchObject({
status: 200,
})

// The RP MUST validate that the aud (audience) Claim contains the value of the client_id
// that the RP sent in the Authorization Request as an audience.
// When the request has been signed, the value might be an HTTPS URL, or a Decentralized Identifier.
await waitForVerificationSessionRecordSubject(verifier.replaySubject, {
contextCorrelationId: verifier.agent.context.contextCorrelationId,
state: OpenId4VcVerificationSessionState.ResponseVerified,
verificationSessionId: verificationSession.id,
})
const { idToken, presentationExchange } =
await verifier.agent.modules.openId4VcVerifier.getVerifiedAuthorizationResponse(verificationSession.id)

expect(idToken).toBeUndefined()

const presentation = presentationExchange?.presentations[0] as SdJwtVc

// name SHOULD NOT be disclosed
expect(presentation.prettyClaims).not.toHaveProperty('name')

// university and name SHOULD NOT be in the signed payload
expect(presentation.payload).not.toHaveProperty('university')
expect(presentation.payload).not.toHaveProperty('name')

expect(presentationExchange).toEqual({
definition: presentationDefinition,
submission: {
definition_id: 'OpenBadgeCredential',
descriptor_map: [
{
format: 'vc+sd-jwt',
id: 'OpenBadgeCredentialDescriptor',
path: '$',
},
],
id: expect.any(String),
},
presentations: [
{
compact: expect.any(String),
header: {
alg: 'EdDSA',
kid: '#z6MkrzQPBr4pyqC776KKtrz13SchM5ePPbssuPuQZb5t4uKQ',
typ: 'vc+sd-jwt',
},
payload: {
_sd: [expect.any(String), expect.any(String)],
_sd_alg: 'sha-256',
cnf: {
kid: 'did:key:z6MkpGR4gs4Rc3Zph4vj8wRnjnAxgAPSxcR8MAVKutWspQzc#z6MkpGR4gs4Rc3Zph4vj8wRnjnAxgAPSxcR8MAVKutWspQzc',
},
iat: expect.any(Number),
iss: 'did:key:z6MkrzQPBr4pyqC776KKtrz13SchM5ePPbssuPuQZb5t4uKQ',
vct: 'OpenBadgeCredential',
degree: 'bachelor',
},
// university SHOULD be disclosed
prettyClaims: {
cnf: {
kid: 'did:key:z6MkpGR4gs4Rc3Zph4vj8wRnjnAxgAPSxcR8MAVKutWspQzc#z6MkpGR4gs4Rc3Zph4vj8wRnjnAxgAPSxcR8MAVKutWspQzc',
},
iat: expect.any(Number),
iss: 'did:key:z6MkrzQPBr4pyqC776KKtrz13SchM5ePPbssuPuQZb5t4uKQ',
vct: 'OpenBadgeCredential',
degree: 'bachelor',
university: 'innsbruck',
},
},
],
})
})
})

0 comments on commit ee45087

Please sign in to comment.