diff --git a/packages/anoncreds/package.json b/packages/anoncreds/package.json index 2d43d708fa..8ff8155a01 100644 --- a/packages/anoncreds/package.json +++ b/packages/anoncreds/package.json @@ -28,7 +28,7 @@ "dependencies": { "@astronautlabs/jsonpath": "^1.1.2", "@credo-ts/core": "workspace:*", - "@sphereon/pex-models": "^2.2.4", + "@sphereon/pex-models": "^2.3.1", "big-integer": "^1.6.51", "bn.js": "^5.2.1", "class-transformer": "0.5.1", diff --git a/packages/core/package.json b/packages/core/package.json index 5aa90b3675..f33db3b5ec 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -44,8 +44,8 @@ "@sd-jwt/sd-jwt-vc": "^0.7.0", "@sd-jwt/types": "^0.7.0", "@sd-jwt/utils": "^0.7.0", - "@sphereon/pex": "^5.0.0-unstable.8", - "@sphereon/pex-models": "^2.2.4", + "@sphereon/pex": "5.0.0-unstable.2", + "@sphereon/pex-models": "^2.3.1", "@sphereon/ssi-types": "0.29.1-unstable.121", "@stablelib/ed25519": "^1.0.2", "@types/ws": "^8.5.4", diff --git a/packages/core/src/crypto/JwsService.ts b/packages/core/src/crypto/JwsService.ts index 88b29d2ebf..eed23e2df2 100644 --- a/packages/core/src/crypto/JwsService.ts +++ b/packages/core/src/crypto/JwsService.ts @@ -110,7 +110,10 @@ export class JwsService { /** * Verify a JWS */ - public async verifyJws(agentContext: AgentContext, { jws, jwkResolver }: VerifyJwsOptions): Promise { + public async verifyJws( + agentContext: AgentContext, + { jws, jwkResolver, trustedCertificates }: VerifyJwsOptions + ): Promise { let signatures: JwsDetachedFormat[] = [] let payload: string @@ -162,6 +165,7 @@ export class JwsService { alg: protectedJson.alg, }, jwkResolver, + trustedCertificates, }) if (!jwk.supportsSignatureAlgorithm(protectedJson.alg)) { throw new CredoError( @@ -223,9 +227,10 @@ export class JwsService { protectedHeader: { alg: string; [key: string]: unknown } payload: string jwkResolver?: JwsJwkResolver + trustedCertificates?: [string, ...string[]] } ): Promise { - const { protectedHeader, jwkResolver, jws, payload } = options + const { protectedHeader, jwkResolver, jws, payload, trustedCertificates: trustedCertificatesFromOptions } = options if ([protectedHeader.jwk, protectedHeader.kid, protectedHeader.x5c].filter(Boolean).length > 1) { throw new CredoError('Only one of jwk, kid and x5c headers can and must be provided.') @@ -239,16 +244,17 @@ export class JwsService { throw new CredoError('x5c header is not a valid JSON array of string.') } - const trustedCertificates = agentContext.dependencyManager.resolve(X509ModuleConfig).trustedCertificates - if (!trustedCertificates) { + const trustedCertificatesFromConfig = agentContext.dependencyManager.resolve(X509ModuleConfig).trustedCertificates + const trustedCertificates = [...(trustedCertificatesFromConfig ?? []), ...(trustedCertificatesFromOptions ?? [])] + if (trustedCertificates.length === 0) { throw new CredoError( - 'No trusted certificates configured for X509 certificate chain validation. Issuer cannot be verified.' + `trustedCertificates is required when the JWS protected header contains an 'x5c' property.` ) } await X509Service.validateCertificateChain(agentContext, { certificateChain: protectedHeader.x5c, - trustedCertificates, + trustedCertificates: trustedCertificates as [string, ...string[]], // Already validated that it has at least one certificate }) const certificate = X509Service.getLeafCertificate(agentContext, { certificateChain: protectedHeader.x5c }) @@ -308,6 +314,8 @@ export interface VerifyJwsOptions { * base on the `iss` property in the JWT payload. */ jwkResolver?: JwsJwkResolver + + trustedCertificates?: [string, ...string[]] } export type JwsJwkResolver = (options: { diff --git a/packages/core/src/modules/dif-presentation-exchange/DifPresentationExchangeService.ts b/packages/core/src/modules/dif-presentation-exchange/DifPresentationExchangeService.ts index ee4dbafd26..ca4da46665 100644 --- a/packages/core/src/modules/dif-presentation-exchange/DifPresentationExchangeService.ts +++ b/packages/core/src/modules/dif-presentation-exchange/DifPresentationExchangeService.ts @@ -22,7 +22,8 @@ import type { W3CVerifiablePresentation, } from '@sphereon/ssi-types' -import { PEVersion, PEX, PresentationSubmissionLocation, Status } from '@sphereon/pex' +import { PEVersion, PEX, Status } from '@sphereon/pex' +import { PartialSdJwtDecodedVerifiableCredential } from '@sphereon/pex/dist/main/lib' import { injectable } from 'tsyringe' import { Hasher, getJwkFromKey } from '../../crypto' @@ -276,9 +277,10 @@ export class DifPresentationExchangeService { }) return { - verifiablePresentations: verifiablePresentationResultsWithFormat.flatMap((resultWithFormat) => - resultWithFormat.verifiablePresentationResult.verifiablePresentations.map((encoded) => - getVerifiablePresentationFromEncoded(agentContext, encoded) + verifiablePresentations: verifiablePresentationResultsWithFormat.map((resultWithFormat) => + getVerifiablePresentationFromEncoded( + agentContext, + resultWithFormat.verifiablePresentationResult.verifiablePresentation ) ), presentationSubmission, @@ -536,7 +538,9 @@ export class DifPresentationExchangeService { return signedPresentation.encoded as W3CVerifiablePresentation } else if (presentationToCreate.claimFormat === ClaimFormat.SdJwtVc) { - const sdJwtInput = presentationInput as SdJwtDecodedVerifiableCredential + const sdJwtInput = presentationInput as + | SdJwtDecodedVerifiableCredential + | PartialSdJwtDecodedVerifiableCredential if (!domain) { throw new CredoError("Missing 'domain' property, unable to set required 'aud' property in SD-JWT KB-JWT") diff --git a/packages/core/src/modules/proofs/formats/dif-presentation-exchange/DifPresentationExchangeProofFormatService.ts b/packages/core/src/modules/proofs/formats/dif-presentation-exchange/DifPresentationExchangeProofFormatService.ts index bccf0f46f1..9ca9168512 100644 --- a/packages/core/src/modules/proofs/formats/dif-presentation-exchange/DifPresentationExchangeProofFormatService.ts +++ b/packages/core/src/modules/proofs/formats/dif-presentation-exchange/DifPresentationExchangeProofFormatService.ts @@ -247,7 +247,7 @@ export class DifPresentationExchangeProofFormatService public async processPresentation( agentContext: AgentContext, - { requestAttachment, attachment }: ProofFormatProcessPresentationOptions + { requestAttachment, attachment, proofRecord }: ProofFormatProcessPresentationOptions ): Promise { const ps = this.presentationExchangeService(agentContext) const w3cCredentialService = agentContext.dependencyManager.resolve(W3cCredentialService) @@ -301,6 +301,9 @@ export class DifPresentationExchangeProofFormatService presentation: parsedPresentation, challenge: request.options.challenge, domain: request.options.domain, + verificationContext: { + didcommProofRecordId: proofRecord.id, + }, }) } else if (parsedPresentation.claimFormat === ClaimFormat.LdpVp) { if ( diff --git a/packages/core/src/modules/vc/W3cCredentialServiceOptions.ts b/packages/core/src/modules/vc/W3cCredentialServiceOptions.ts index 3a9b892e89..10e7679016 100644 --- a/packages/core/src/modules/vc/W3cCredentialServiceOptions.ts +++ b/packages/core/src/modules/vc/W3cCredentialServiceOptions.ts @@ -179,8 +179,22 @@ interface W3cVerifyPresentationOptionsBase { verifyCredentialStatus?: boolean } +export interface VerificationContext { + /** + * The `id` of the `ProofRecord` that this verification is bound to. + */ + didcommProofRecordId?: string + + /** + * The `id` of the `OpenId4VcVerificationSessionRecord` that this verification is bound to. + */ + openId4VcVerificationSessionId?: string +} + export interface W3cJwtVerifyPresentationOptions extends W3cVerifyPresentationOptionsBase { presentation: W3cJwtVerifiablePresentation | string // string must be encoded VP JWT + trustedCertificates?: [string, ...string[]] + verificationContext?: VerificationContext } export interface W3cJsonLdVerifyPresentationOptions extends W3cVerifyPresentationOptionsBase { diff --git a/packages/core/src/modules/vc/jwt-vc/W3cJwtCredentialService.ts b/packages/core/src/modules/vc/jwt-vc/W3cJwtCredentialService.ts index 3f4e442b59..fdbd8fb3ab 100644 --- a/packages/core/src/modules/vc/jwt-vc/W3cJwtCredentialService.ts +++ b/packages/core/src/modules/vc/jwt-vc/W3cJwtCredentialService.ts @@ -15,6 +15,7 @@ import { CredoError } from '../../../error' import { injectable } from '../../../plugins' import { asArray, isDid, MessageValidator } from '../../../utils' import { getKeyDidMappingByKeyType, DidResolverService, getKeyFromVerificationMethod } from '../../dids' +import { X509ModuleConfig } from '../../x509' import { W3cJsonLdVerifiableCredential } from '../data-integrity' import { W3cJwtVerifiableCredential } from './W3cJwtVerifiableCredential' @@ -308,6 +309,10 @@ export class W3cJwtCredentialService { const proverPublicKey = getKeyFromVerificationMethod(proverVerificationMethod) const proverPublicJwk = getJwkFromKey(proverPublicKey) + const getTrustedCertificatesForVerification = agentContext.dependencyManager.isRegistered(X509ModuleConfig) + ? agentContext.dependencyManager.resolve(X509ModuleConfig).getTrustedCertificatesForVerification + : undefined + let signatureResult: VerifyJwsResult | undefined = undefined try { // Verify the JWS signature @@ -315,6 +320,9 @@ export class W3cJwtCredentialService { jws: presentation.jwt.serializedJwt, // We have pre-fetched the key based on the singer/holder of the presentation jwkResolver: () => proverPublicJwk, + trustedCertificates: + options.trustedCertificates ?? + (await getTrustedCertificatesForVerification?.(agentContext, options.verificationContext)), }) if (!signatureResult.isValid) { diff --git a/packages/core/src/modules/vc/jwt-vc/__tests__/W3cJwtCredentialService.test.ts b/packages/core/src/modules/vc/jwt-vc/__tests__/W3cJwtCredentialService.test.ts index 72eb5c71f1..e114c5dd7b 100644 --- a/packages/core/src/modules/vc/jwt-vc/__tests__/W3cJwtCredentialService.test.ts +++ b/packages/core/src/modules/vc/jwt-vc/__tests__/W3cJwtCredentialService.test.ts @@ -7,6 +7,7 @@ import { getJwkFromKey } from '../../../../crypto/jose/jwk' import { CredoError, ClassValidationError } from '../../../../error' import { JsonTransformer } from '../../../../utils' import { DidJwk, DidKey, DidRepository, DidsModuleConfig } from '../../../dids' +import { X509ModuleConfig } from '../../../x509' import { CREDENTIALS_CONTEXT_V1_URL } from '../../constants' import { ClaimFormat, W3cCredential, W3cPresentation } from '../../models' import { W3cJwtCredentialService } from '../W3cJwtCredentialService' @@ -30,6 +31,7 @@ const agentContext = getAgentContext({ [InjectionSymbols.Logger, testLogger], [DidsModuleConfig, new DidsModuleConfig()], [DidRepository, {} as unknown as DidRepository], + [X509ModuleConfig, new X509ModuleConfig()], ], agentConfig: config, }) diff --git a/packages/core/src/modules/x509/X509ModuleConfig.ts b/packages/core/src/modules/x509/X509ModuleConfig.ts index 5fcd99a076..97ea419393 100644 --- a/packages/core/src/modules/x509/X509ModuleConfig.ts +++ b/packages/core/src/modules/x509/X509ModuleConfig.ts @@ -1,9 +1,25 @@ +import type { AgentContext } from '../../agent' +import type { VerificationContext } from '../vc' + export interface X509ModuleConfigOptions { /** * * Array of trusted base64-encoded certificate strings in the DER-format. */ trustedCertificates?: [string, ...string[]] + + /** + * Optional callback method that will be called to dynamically get trusted certificates for a verification. + * It will always provide the `agentContext` allowing to dynamically set the trusted certificates for a tenant. + * If available the associated record id is also provided allowing to filter down trusted certificates to a single + * exchange. + * + * @returns An array of base64-encoded certificate strings or PEM certificate strings. + */ + getTrustedCertificatesForVerification?( + agentContext: AgentContext, + verificationContext?: VerificationContext + ): Promise<[string, ...string[]] | undefined> } export class X509ModuleConfig { @@ -11,12 +27,21 @@ export class X509ModuleConfig { public constructor(options?: X509ModuleConfigOptions) { this.options = options?.trustedCertificates ? { trustedCertificates: [...options.trustedCertificates] } : {} + this.options.getTrustedCertificatesForVerification = options?.getTrustedCertificatesForVerification } public get trustedCertificates() { return this.options.trustedCertificates } + public get getTrustedCertificatesForVerification() { + return this.options.getTrustedCertificatesForVerification + } + + public setTrustedCertificatesForVerification(fn: X509ModuleConfigOptions['getTrustedCertificatesForVerification']) { + this.options.getTrustedCertificatesForVerification = fn + } + public setTrustedCertificates(trustedCertificates?: [string, ...string[]]) { this.options.trustedCertificates = trustedCertificates ? [...trustedCertificates] : undefined } diff --git a/packages/openid4vc/src/openid4vc-holder/__tests__/fixtures.ts b/packages/openid4vc/src/openid4vc-holder/__tests__/fixtures.ts index 183dd1b778..6f5b58d71c 100644 --- a/packages/openid4vc/src/openid4vc-holder/__tests__/fixtures.ts +++ b/packages/openid4vc/src/openid4vc-holder/__tests__/fixtures.ts @@ -126,8 +126,10 @@ export const matrrLaunchpadDraft11JwtVcJson = { } export const waltIdDraft11JwtVcJson = { - credentialOffer: + credentialOfferPreAuth: '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', + credentialOfferAuth: + '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', getMetadataResponse: { issuer: 'https://issuer.portal.walt.id', authorization_endpoint: 'https://issuer.portal.walt.id/authorize', diff --git a/packages/openid4vc/src/openid4vc-holder/__tests__/openid4vci-holder.test.ts b/packages/openid4vc/src/openid4vc-holder/__tests__/openid4vci-holder.test.ts index fc4efc421d..2d37bfd355 100644 --- a/packages/openid4vc/src/openid4vc-holder/__tests__/openid4vci-holder.test.ts +++ b/packages/openid4vc/src/openid4vc-holder/__tests__/openid4vci-holder.test.ts @@ -136,7 +136,7 @@ describe('OpenId4VcHolder', () => { .post('/credential') .reply(200, fixture.credentialResponse) - const resolved = await holder.modules.openId4VcHolder.resolveCredentialOffer(fixture.credentialOffer) + const resolved = await holder.modules.openId4VcHolder.resolveCredentialOffer(fixture.credentialOfferPreAuth) await expect(() => holder.modules.openId4VcHolder.acceptCredentialOfferUsingPreAuthorizedCode(resolved, { @@ -286,7 +286,7 @@ describe('OpenId4VcHolder', () => { .reply(404) const resolvedCredentialOffer = await holder.modules.openId4VcHolder.resolveCredentialOffer( - fixture.credentialOffer + fixture.credentialOfferAuth ) const resolvedAuthorizationRequest = await holder.modules.openId4VcHolder.resolveIssuanceAuthorizationRequest( diff --git a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts index c6b511b2be..baff7edb7f 100644 --- a/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts +++ b/packages/openid4vc/src/openid4vc-verifier/OpenId4VcSiopVerifierService.ts @@ -242,8 +242,8 @@ export class OpenId4VcSiopVerifierService { const relyingParty = await this.getRelyingParty(agentContext, options.verificationSession.verifierId, { presentationDefinition: presentationDefinitionsWithLocation?.[0]?.definition, - clientId: requestClientId, authorizationResponseUrl, + clientId: requestClientId, }) // This is very unfortunate, but storing state in sphereon's SiOP-OID4VP library @@ -281,6 +281,7 @@ export class OpenId4VcSiopVerifierService { presentationDefinitions: presentationDefinitionsWithLocation, verification: { presentationVerificationCallback: this.getPresentationVerificationCallback(agentContext, { + correlationId: options.verificationSession.id, nonce: requestNonce, audience: requestClientId, }), @@ -331,10 +332,12 @@ export class OpenId4VcSiopVerifierService { throw new CredoError('Unable to extract submission from the response.') } - const vps = Array.isArray(presentations) ? presentations : [presentations] + // FIXME: should return type be an array? As now it doesn't always match the submission + const presentationsArray = Array.isArray(presentations) ? presentations : [presentations] + presentationExchange = { definition: presentationDefinitions[0].definition, - presentations: vps.map(getVerifiablePresentationFromSphereonWrapped), + presentations: presentationsArray.map(getVerifiablePresentationFromSphereonWrapped), submission, } } @@ -459,10 +462,10 @@ export class OpenId4VcSiopVerifierService { responseMode, }: { responseMode?: ResponseMode - authorizationResponseUrl: string idToken?: boolean presentationDefinition?: DifPresentationExchangeDefinition clientId: string + authorizationResponseUrl: string clientIdScheme?: ClientIdScheme } ) { @@ -519,6 +522,7 @@ export class OpenId4VcSiopVerifierService { : undefined builder + .withClientId(clientId) .withResponseUri(authorizationResponseUrl) .withIssuer(ResponseIss.SELF_ISSUED_V2) .withAudience(RequestAud.SELF_ISSUED_V2) @@ -541,9 +545,11 @@ export class OpenId4VcSiopVerifierService { // TODO: we should probably allow some dynamic values here .withClientMetadata({ - client_id: clientId, ...jarmClientMetadata, - client_id_scheme: clientIdScheme, + // FIXME: not passing client_id here means it will not be added + // to the authorization request url (not the signed payload). Need + // to fix that in Sphereon lib + client_id: clientId, passBy: PassBy.VALUE, responseTypesSupported: [ResponseType.VP_TOKEN], subject_syntax_types_supported: supportedDidMethods.map((m) => `did:${m}`), @@ -586,7 +592,7 @@ export class OpenId4VcSiopVerifierService { private getPresentationVerificationCallback( agentContext: AgentContext, - options: { nonce: string; audience: string } + options: { nonce: string; audience: string; correlationId: string } ): PresentationVerificationCallback { return async (encodedPresentation, presentationSubmission) => { try { @@ -616,6 +622,9 @@ export class OpenId4VcSiopVerifierService { presentation: encodedPresentation, challenge: options.nonce, domain: options.audience, + verificationContext: { + openId4VcVerificationSessionId: options.correlationId, + }, }) isValid = verificationResult.isValid 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 d1d1662649..e40ef70579 100644 --- a/packages/openid4vc/src/openid4vc-verifier/__tests__/openid4vc-verifier.test.ts +++ b/packages/openid4vc/src/openid4vc-verifier/__tests__/openid4vc-verifier.test.ts @@ -62,6 +62,7 @@ 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(undefined) 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 ee26523d96..99c95ff974 100644 --- a/packages/openid4vc/src/openid4vc-verifier/router/authorizationEndpoint.ts +++ b/packages/openid4vc/src/openid4vc-verifier/router/authorizationEndpoint.ts @@ -103,16 +103,22 @@ export function configureAuthorizationEndpoint(router: Router, config: OpenId4Vc nonce: authorizationResponsePayload.nonce, }) } - if (typeof authorizationResponsePayload.presentation_submission === 'string') { - authorizationResponsePayload.presentation_submission = JSON.parse( - authorizationResponsePayload.presentation_submission - ) + authorizationResponsePayload.presentation_submission = JSON.parse(request.body.presentation_submission) + } + + // This feels hacky, and should probably be moved to OID4VP lib. However the OID4VP spec allows either object, string, or array... + if ( + typeof authorizationResponsePayload.vp_token === 'string' && + (authorizationResponsePayload.vp_token.startsWith('{') || authorizationResponsePayload.vp_token.startsWith('[')) + ) { + authorizationResponsePayload.vp_token = JSON.parse(authorizationResponsePayload.vp_token) } if (!verificationSession) { throw new CredoError('Missing verification session, cannot verify authorization response.') } + await openId4VcVerifierService.verifyAuthorizationResponse(agentContext, { authorizationResponse: authorizationResponsePayload, verificationSession, diff --git a/packages/openid4vc/src/shared/utils.ts b/packages/openid4vc/src/shared/utils.ts index c5e46aa027..f47fca1d1f 100644 --- a/packages/openid4vc/src/shared/utils.ts +++ b/packages/openid4vc/src/shared/utils.ts @@ -11,6 +11,7 @@ import { JwtPayload, SignatureSuiteRegistry, X509Service, + getDomainFromUrl, getJwkClassFromKeyType, getJwkFromJson, getJwkFromKey, @@ -135,6 +136,19 @@ export async function openIdTokenIssuerToJwtIssuer( throw new CredoError(`No supported signature algorithms found key type: '${jwk.keyType}'`) } + if (!openId4VcTokenIssuer.issuer.startsWith('https://')) { + throw new CredoError('The X509 certificate issuer must be a HTTPS URI.') + } + + if ( + !leafCertificate.sanUriNames?.includes(openId4VcTokenIssuer.issuer) && + !leafCertificate.sanDnsNames?.includes(getDomainFromUrl(openId4VcTokenIssuer.issuer)) + ) { + throw new Error( + `The 'iss' claim in the payload does not match a 'SAN-URI' or 'SAN-DNS' name in the x5c certificate.` + ) + } + return { ...openId4VcTokenIssuer, alg, diff --git a/packages/openid4vc/tests/openid4vc.e2e.test.ts b/packages/openid4vc/tests/openid4vc.e2e.test.ts index ec6101437e..1409395523 100644 --- a/packages/openid4vc/tests/openid4vc.e2e.test.ts +++ b/packages/openid4vc/tests/openid4vc.e2e.test.ts @@ -30,6 +30,7 @@ import { OpenId4VcHolderModule, OpenId4VcIssuanceSessionState, OpenId4VcIssuerModule, + OpenId4VcVerificationSessionRepository, OpenId4VcVerificationSessionState, OpenId4VcVerifierModule, } from '../src' @@ -768,6 +769,12 @@ describe('OpenId4Vc', () => { ], } satisfies DifPresentationExchangeDefinitionV2 + // Hack to make it work with x5c check + // @ts-expect-error + verifier.agent.modules.openId4VcVerifier.config.options.baseUrl = + // @ts-expect-error + verifier.agent.modules.openId4VcVerifier.config.options.baseUrl.replace('http://', 'https://') + const { authorizationRequest, verificationSession } = await verifier.agent.modules.openId4VcVerifier.createAuthorizationRequest({ verifierId: openIdVerifier.verifierId, @@ -782,14 +789,28 @@ describe('OpenId4Vc', () => { }, }) - expect(authorizationRequest).toEqual( + // Hack to make it work with x5c checks + verificationSession.authorizationRequestUri = verificationSession.authorizationRequestUri.replace('https', 'http') + const verificationSessionRepoitory = verifier.agent.dependencyManager.resolve( + OpenId4VcVerificationSessionRepository + ) + await verificationSessionRepoitory.update(verifier.agent.context, verificationSession) + + // Hack to make it work with x5c check + // @ts-expect-error + verifier.agent.modules.openId4VcVerifier.config.options.baseUrl = + // @ts-expect-error + verifier.agent.modules.openId4VcVerifier.config.options.baseUrl.replace('https://', 'http://') + + expect(authorizationRequest.replace('https', 'http')).toEqual( `openid4vp://?client_id=localhost%3A1234&request_uri=${encodeURIComponent( verificationSession.authorizationRequestUri )}` ) const resolvedAuthorizationRequest = await holder.agent.modules.openId4VcHolder.resolveSiopAuthorizationRequest( - authorizationRequest + // hack to make it work on localhost + authorizationRequest.replace('https', 'http') ) expect(resolvedAuthorizationRequest.authorizationRequest.payload?.response_mode).toEqual('direct_post.jwt') @@ -842,6 +863,10 @@ describe('OpenId4Vc', () => { resolvedAuthorizationRequest.presentationExchange.credentialsForRequest ) + // Hack to make it work with x5c + resolvedAuthorizationRequest.authorizationRequest.responseURI = + resolvedAuthorizationRequest.authorizationRequest.responseURI?.replace('https', 'http') + const { serverResponse, submittedResponse } = await holder.agent.modules.openId4VcHolder.acceptSiopAuthorizationRequest({ authorizationRequest: resolvedAuthorizationRequest.authorizationRequest, @@ -1001,6 +1026,12 @@ describe('OpenId4Vc', () => { ], } satisfies DifPresentationExchangeDefinitionV2 + // Hack to make it work with x5c check + // @ts-expect-error + verifier.agent.modules.openId4VcVerifier.config.options.baseUrl = + // @ts-expect-error + verifier.agent.modules.openId4VcVerifier.config.options.baseUrl.replace('http://', 'https://') + const { authorizationRequest, verificationSession } = await verifier.agent.modules.openId4VcVerifier.createAuthorizationRequest({ verifierId: openIdVerifier.verifierId, @@ -1014,14 +1045,28 @@ describe('OpenId4Vc', () => { }, }) - expect(authorizationRequest).toEqual( + // Hack to make it work with x5c checks + verificationSession.authorizationRequestUri = verificationSession.authorizationRequestUri.replace('https', 'http') + const verificationSessionRepoitory = verifier.agent.dependencyManager.resolve( + OpenId4VcVerificationSessionRepository + ) + await verificationSessionRepoitory.update(verifier.agent.context, verificationSession) + + // Hack to make it work with x5c check + // @ts-expect-error + verifier.agent.modules.openId4VcVerifier.config.options.baseUrl = + // @ts-expect-error + verifier.agent.modules.openId4VcVerifier.config.options.baseUrl.replace('https://', 'http://') + + expect(authorizationRequest.replace('https', 'http')).toEqual( `openid4vp://?client_id=localhost%3A1234&request_uri=${encodeURIComponent( verificationSession.authorizationRequestUri )}` ) const resolvedAuthorizationRequest = await holder.agent.modules.openId4VcHolder.resolveSiopAuthorizationRequest( - authorizationRequest + // hack to make it work on localhost + authorizationRequest.replace('https', 'http') ) expect(resolvedAuthorizationRequest.presentationExchange?.credentialsForRequest).toEqual({ @@ -1073,6 +1118,10 @@ describe('OpenId4Vc', () => { resolvedAuthorizationRequest.presentationExchange.credentialsForRequest ) + // Hack to make it work with x5c + resolvedAuthorizationRequest.authorizationRequest.responseURI = + resolvedAuthorizationRequest.authorizationRequest.responseURI?.replace('https', 'http') + const { serverResponse, submittedResponse } = await holder.agent.modules.openId4VcHolder.acceptSiopAuthorizationRequest({ authorizationRequest: resolvedAuthorizationRequest.authorizationRequest, @@ -1171,4 +1220,368 @@ describe('OpenId4Vc', () => { ], }) }) + + it('e2e flow with verifier endpoints verifying two sd-jwt-vcs 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 signedSdJwtVc2 = await issuer.agent.sdJwtVc.sign({ + holder: { method: 'did', didUrl: holder.kid }, + issuer: { + method: 'did', + didUrl: issuer.kid, + }, + payload: { + vct: 'OpenBadgeCredential2', + university: 'innsbruck2', + degree: 'bachelor2', + name: 'John Doe2', + }, + 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.sdJwtVc.store(signedSdJwtVc2.compact) + + await holder.agent.x509.addTrustedCertificate(rawCertificate) + await verifier.agent.x509.addTrustedCertificate(rawCertificate) + + const presentationDefinition = { + id: 'OpenBadgeCredentials', + 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'], + }, + ], + }, + }, + { + id: 'OpenBadgeCredentialDescriptor2', + format: { + 'vc+sd-jwt': { + 'sd-jwt_alg_values': ['EdDSA'], + }, + }, + constraints: { + limit_disclosure: 'required', + fields: [ + { + path: ['$.vct'], + filter: { + type: 'string', + const: 'OpenBadgeCredential2', + }, + }, + { + path: ['$.name'], + }, + ], + }, + }, + ], + } satisfies DifPresentationExchangeDefinitionV2 + + // Hack to make it work with x5c check + // @ts-expect-error + verifier.agent.modules.openId4VcVerifier.config.options.baseUrl = + // @ts-expect-error + verifier.agent.modules.openId4VcVerifier.config.options.baseUrl.replace('http://', 'https://') + const { authorizationRequest, verificationSession } = + await verifier.agent.modules.openId4VcVerifier.createAuthorizationRequest({ + verifierId: openIdVerifier.verifierId, + + requestSigner: { + method: 'x5c', + x5c: [rawCertificate], + }, + presentationExchange: { + definition: presentationDefinition, + }, + }) + + // Hack to make it work with x5c checks + verificationSession.authorizationRequestUri = verificationSession.authorizationRequestUri.replace('https', 'http') + const verificationSessionRepoitory = verifier.agent.dependencyManager.resolve( + OpenId4VcVerificationSessionRepository + ) + await verificationSessionRepoitory.update(verifier.agent.context, verificationSession) + + // Hack to make it work with x5c check + // @ts-expect-error + verifier.agent.modules.openId4VcVerifier.config.options.baseUrl = + // @ts-expect-error + verifier.agent.modules.openId4VcVerifier.config.options.baseUrl.replace('https://', 'http://') + + expect(authorizationRequest.replace('https', 'http')).toEqual( + `openid4vp://?client_id=localhost%3A1234&request_uri=${encodeURIComponent( + verificationSession.authorizationRequestUri + )}` + ) + + const resolvedAuthorizationRequest = await holder.agent.modules.openId4VcHolder.resolveSiopAuthorizationRequest( + // hack to make it work on localhost + authorizationRequest.replace('https', 'http') + ) + + expect(resolvedAuthorizationRequest.presentationExchange?.credentialsForRequest).toEqual({ + areRequirementsSatisfied: true, + name: undefined, + purpose: undefined, + requirements: expect.arrayContaining([ + { + 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', + }, + }, + ], + }, + ], + }, + { + isRequirementSatisfied: true, + needsCount: 1, + rule: 'pick', + submissionEntry: [ + { + name: undefined, + purpose: undefined, + inputDescriptorId: 'OpenBadgeCredentialDescriptor2', + verifiableCredentials: [ + { + type: ClaimFormat.SdJwtVc, + credentialRecord: expect.objectContaining({ + compactSdJwtVc: signedSdJwtVc2.compact, + }), + disclosedPayload: { + cnf: { + kid: 'did:key:z6MkpGR4gs4Rc3Zph4vj8wRnjnAxgAPSxcR8MAVKutWspQzc#z6MkpGR4gs4Rc3Zph4vj8wRnjnAxgAPSxcR8MAVKutWspQzc', + }, + iat: expect.any(Number), + iss: 'did:key:z6MkrzQPBr4pyqC776KKtrz13SchM5ePPbssuPuQZb5t4uKQ', + vct: 'OpenBadgeCredential2', + degree: 'bachelor2', + name: 'John Doe2', + }, + }, + ], + }, + ], + }, + ]), + }) + + 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 + ) + + // Hack to make it work with x5c + resolvedAuthorizationRequest.authorizationRequest.responseURI = + resolvedAuthorizationRequest.authorizationRequest.responseURI?.replace('https', 'http') + + 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: 'OpenBadgeCredentials', + descriptor_map: [ + { + format: 'vc+sd-jwt', + id: 'OpenBadgeCredentialDescriptor', + path: '$[0]', + }, + { + format: 'vc+sd-jwt', + id: 'OpenBadgeCredentialDescriptor2', + path: '$[1]', + }, + ], + id: expect.any(String), + }, + state: expect.any(String), + vp_token: [expect.any(String), 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: 'OpenBadgeCredentials', + descriptor_map: [ + { + format: 'vc+sd-jwt', + id: 'OpenBadgeCredentialDescriptor', + path: '$[0]', + }, + { + format: 'vc+sd-jwt', + id: 'OpenBadgeCredentialDescriptor2', + path: '$[1]', + }, + ], + 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', + }, + }, + { + 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: 'OpenBadgeCredential2', + degree: 'bachelor2', + }, + prettyClaims: { + cnf: { + kid: 'did:key:z6MkpGR4gs4Rc3Zph4vj8wRnjnAxgAPSxcR8MAVKutWspQzc#z6MkpGR4gs4Rc3Zph4vj8wRnjnAxgAPSxcR8MAVKutWspQzc', + }, + iat: expect.any(Number), + iss: 'did:key:z6MkrzQPBr4pyqC776KKtrz13SchM5ePPbssuPuQZb5t4uKQ', + vct: 'OpenBadgeCredential2', + name: 'John Doe2', + degree: 'bachelor2', + }, + }, + ], + }) + }) }) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 68bfd2bec9..ed95e2ff16 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -237,7 +237,7 @@ importers: specifier: workspace:* version: link:../core '@sphereon/pex-models': - specifier: ^2.2.4 + specifier: ^2.3.1 version: 2.3.1 big-integer: specifier: ^1.6.51 @@ -460,10 +460,10 @@ importers: specifier: ^0.7.0 version: 0.7.2 '@sphereon/pex': - specifier: ^5.0.0-unstable.8 - version: 5.0.0-unstable.8(ts-node@10.9.2(@types/node@18.18.8)(typescript@5.5.4)) + specifier: 5.0.0-unstable.2 + version: 5.0.0-unstable.2(ts-node@10.9.2(@types/node@18.18.8)(typescript@5.5.4)) '@sphereon/pex-models': - specifier: ^2.2.4 + specifier: ^2.3.1 version: 2.3.1 '@sphereon/ssi-types': specifier: 0.29.1-unstable.121 @@ -2536,10 +2536,6 @@ packages: resolution: {integrity: sha512-mA6lY/OBKKzsh4Jf4btm9Tj4ymVsX6xuVATn85LurD4bt3fhZwNJMkxhFy4tT/QyAtp05E4aaEq0wTVvOjVa7w==} engines: {node: '>=18'} - '@sphereon/pex@5.0.0-unstable.8': - resolution: {integrity: sha512-DD85XvyK2F+7VH4lRmfalqSiET8SJysY3ryIPuTVfYrZ3KssYCFfxTcip0+mgjAgbTUldjZYIXuhJO42TDjA/A==} - engines: {node: '>=18'} - '@sphereon/ssi-sdk-ext.did-utils@0.24.1-unstable.112': resolution: {integrity: sha512-nc0jFPOWg0H20S8m83aQUpNym0Wx0rJCGkgpH6GdK8gBtgza8Y9DvAap1AYZug18WbqPcF6rBjvtIJqAKsSvlQ==} @@ -10452,41 +10448,6 @@ snapshots: - ts-node - typeorm-aurora-data-api-driver - '@sphereon/pex@5.0.0-unstable.8(ts-node@10.9.2(@types/node@18.18.8)(typescript@5.5.4))': - dependencies: - '@astronautlabs/jsonpath': 1.1.2 - '@sd-jwt/decode': 0.6.1 - '@sd-jwt/present': 0.6.1 - '@sd-jwt/types': 0.6.1 - '@sphereon/pex-models': 2.3.1 - '@sphereon/ssi-types': 0.29.1-unstable.121(ts-node@10.9.2(@types/node@18.18.8)(typescript@5.5.4)) - ajv: 8.17.1 - ajv-formats: 2.1.1(ajv@8.17.1) - jwt-decode: 3.1.2 - nanoid: 3.3.7 - string.prototype.matchall: 4.0.11 - uint8arrays: 3.1.1 - transitivePeerDependencies: - - '@google-cloud/spanner' - - '@sap/hana-client' - - better-sqlite3 - - encoding - - hdb-pool - - ioredis - - mongodb - - mssql - - mysql2 - - oracledb - - pg - - pg-native - - pg-query-stream - - redis - - sql.js - - sqlite3 - - supports-color - - ts-node - - typeorm-aurora-data-api-driver - '@sphereon/ssi-sdk-ext.did-utils@0.24.1-unstable.112(ts-node@10.9.2(@types/node@18.18.8)(typescript@5.5.4))': dependencies: '@ethersproject/networks': 5.7.1