diff --git a/packages/core/package.json b/packages/core/package.json index 4a0306f9b8..04813c5fdd 100644 --- a/packages/core/package.json +++ b/packages/core/package.json @@ -33,6 +33,9 @@ "@stablelib/ed25519": "^1.0.2", "@stablelib/random": "^1.0.1", "@stablelib/sha256": "^1.0.1", + "@sphereon/pex": "^2.2.2", + "@sphereon/pex-models": "^2.1.2", + "@sphereon/ssi-types": "^0.17.5", "@types/ws": "^8.5.4", "abort-controller": "^3.0.0", "big-integer": "^1.6.51", diff --git a/packages/core/src/agent/AgentModules.ts b/packages/core/src/agent/AgentModules.ts index ed0afcede9..faf87ecec7 100644 --- a/packages/core/src/agent/AgentModules.ts +++ b/packages/core/src/agent/AgentModules.ts @@ -7,6 +7,7 @@ import { CacheModule } from '../modules/cache' import { ConnectionsModule } from '../modules/connections' import { CredentialsModule } from '../modules/credentials' import { DidsModule } from '../modules/dids' +import { DifPresentationExchangeModule } from '../modules/dif-presentation-exchange' import { DiscoverFeaturesModule } from '../modules/discover-features' import { GenericRecordsModule } from '../modules/generic-records' import { MessagePickupModule } from '../modules/message-pickup' @@ -131,6 +132,7 @@ function getDefaultAgentModules() { oob: () => new OutOfBandModule(), w3cCredentials: () => new W3cCredentialsModule(), cache: () => new CacheModule(), + pex: () => new DifPresentationExchangeModule(), } as const } diff --git a/packages/core/src/agent/__tests__/AgentModules.test.ts b/packages/core/src/agent/__tests__/AgentModules.test.ts index 7717608581..a4c3be88a3 100644 --- a/packages/core/src/agent/__tests__/AgentModules.test.ts +++ b/packages/core/src/agent/__tests__/AgentModules.test.ts @@ -5,6 +5,7 @@ import { CacheModule } from '../../modules/cache' import { ConnectionsModule } from '../../modules/connections' import { CredentialsModule } from '../../modules/credentials' import { DidsModule } from '../../modules/dids' +import { DifPresentationExchangeModule } from '../../modules/dif-presentation-exchange' import { DiscoverFeaturesModule } from '../../modules/discover-features' import { GenericRecordsModule } from '../../modules/generic-records' import { MessagePickupModule } from '../../modules/message-pickup' @@ -62,6 +63,7 @@ describe('AgentModules', () => { mediationRecipient: expect.any(MediationRecipientModule), messagePickup: expect.any(MessagePickupModule), basicMessages: expect.any(BasicMessagesModule), + pex: expect.any(DifPresentationExchangeModule), genericRecords: expect.any(GenericRecordsModule), discovery: expect.any(DiscoverFeaturesModule), dids: expect.any(DidsModule), @@ -86,6 +88,7 @@ describe('AgentModules', () => { mediationRecipient: expect.any(MediationRecipientModule), messagePickup: expect.any(MessagePickupModule), basicMessages: expect.any(BasicMessagesModule), + pex: expect.any(DifPresentationExchangeModule), genericRecords: expect.any(GenericRecordsModule), discovery: expect.any(DiscoverFeaturesModule), dids: expect.any(DidsModule), @@ -113,6 +116,7 @@ describe('AgentModules', () => { mediationRecipient: expect.any(MediationRecipientModule), messagePickup: expect.any(MessagePickupModule), basicMessages: expect.any(BasicMessagesModule), + pex: expect.any(DifPresentationExchangeModule), genericRecords: expect.any(GenericRecordsModule), discovery: expect.any(DiscoverFeaturesModule), dids: expect.any(DidsModule), diff --git a/packages/core/src/decorators/attachment/Attachment.ts b/packages/core/src/decorators/attachment/Attachment.ts index 85996ff039..3a91065b56 100644 --- a/packages/core/src/decorators/attachment/Attachment.ts +++ b/packages/core/src/decorators/attachment/Attachment.ts @@ -4,6 +4,7 @@ import { Expose, Type } from 'class-transformer' import { IsDate, IsHash, IsInstance, IsInt, IsMimeType, IsOptional, IsString, ValidateNested } from 'class-validator' import { AriesFrameworkError } from '../../error' +import { JsonValue } from '../../types' import { JsonEncoder } from '../../utils/JsonEncoder' import { uuid } from '../../utils/uuid' @@ -19,7 +20,7 @@ export interface AttachmentOptions { export interface AttachmentDataOptions { base64?: string - json?: Record + json?: JsonValue links?: string[] jws?: JwsDetachedFormat | JwsFlattenedDetachedFormat sha256?: string @@ -40,7 +41,7 @@ export class AttachmentData { * Directly embedded JSON data, when representing content inline instead of via links, and when the content is natively conveyable as JSON. Optional. */ @IsOptional() - public json?: Record + public json?: JsonValue /** * A list of zero or more locations at which the content may be fetched. Optional. diff --git a/packages/core/src/modules/dif-presentation-exchange/DifPresentationExchangeError.ts b/packages/core/src/modules/dif-presentation-exchange/DifPresentationExchangeError.ts new file mode 100644 index 0000000000..5c06ec420a --- /dev/null +++ b/packages/core/src/modules/dif-presentation-exchange/DifPresentationExchangeError.ts @@ -0,0 +1,13 @@ +import { AriesFrameworkError } from '../../error' + +export class DifPresentationExchangeError extends AriesFrameworkError { + public additionalMessages?: Array + + public constructor( + message: string, + { cause, additionalMessages }: { cause?: Error; additionalMessages?: Array } = {} + ) { + super(message, { cause }) + this.additionalMessages = additionalMessages + } +} diff --git a/packages/core/src/modules/dif-presentation-exchange/DifPresentationExchangeModule.ts b/packages/core/src/modules/dif-presentation-exchange/DifPresentationExchangeModule.ts new file mode 100644 index 0000000000..7cb2c86c5a --- /dev/null +++ b/packages/core/src/modules/dif-presentation-exchange/DifPresentationExchangeModule.ts @@ -0,0 +1,25 @@ +import type { DependencyManager, Module } from '../../plugins' + +import { AgentConfig } from '../../agent/AgentConfig' + +import { DifPresentationExchangeService } from './DifPresentationExchangeService' + +/** + * @public + */ +export class DifPresentationExchangeModule implements Module { + /** + * Registers the dependencies of the presentation-exchange module on the dependency manager. + */ + public register(dependencyManager: DependencyManager) { + // Warn about experimental module + dependencyManager + .resolve(AgentConfig) + .logger.warn( + "The 'DifPresentationExchangeModule' module is experimental and could have unexpected breaking changes. When using this module, make sure to use strict versions for all @aries-framework packages." + ) + + // service + dependencyManager.registerSingleton(DifPresentationExchangeService) + } +} diff --git a/packages/core/src/modules/presentation-exchange/PresentationExchangeService.ts b/packages/core/src/modules/dif-presentation-exchange/DifPresentationExchangeService.ts similarity index 71% rename from packages/core/src/modules/presentation-exchange/PresentationExchangeService.ts rename to packages/core/src/modules/dif-presentation-exchange/DifPresentationExchangeService.ts index 5b3f1d54f6..eab8642230 100644 --- a/packages/core/src/modules/presentation-exchange/PresentationExchangeService.ts +++ b/packages/core/src/modules/dif-presentation-exchange/DifPresentationExchangeService.ts @@ -1,24 +1,24 @@ -import type { InputDescriptorToCredentials, PresentationSubmission } from './models' +import type { + DifPexInputDescriptorToCredentials, + DifPexCredentialsForRequest, + DifPresentationExchangeDefinition, + DifPresentationExchangeDefinitionV1, + DifPresentationExchangeSubmission, + DifPresentationExchangeDefinitionV2, +} from './models' import type { AgentContext } from '../../agent' import type { Query } from '../../storage/StorageService' import type { VerificationMethod } from '../dids' import type { W3cCredentialRecord, W3cVerifiableCredential, W3cVerifiablePresentation } from '../vc' -import type { - IPresentationDefinition, - PresentationSignCallBackParams, - VerifiablePresentationResult, -} from '@sphereon/pex' -import type { - InputDescriptorV2, - PresentationSubmission as PexPresentationSubmission, - PresentationDefinitionV1, -} from '@sphereon/pex-models' -import type { OriginalVerifiableCredential } from '@sphereon/ssi-types' +import type { PresentationSignCallBackParams, Validated, VerifiablePresentationResult } from '@sphereon/pex' +import type { InputDescriptorV2, PresentationDefinitionV1 } from '@sphereon/pex-models' +import type { OriginalVerifiableCredential, OriginalVerifiablePresentation } from '@sphereon/ssi-types' -import { PEVersion, PEX, PresentationSubmissionLocation } from '@sphereon/pex' +import { Status, PEVersion, PEX } from '@sphereon/pex' import { injectable } from 'tsyringe' import { getJwkFromKey } from '../../crypto' +import { AriesFrameworkError } from '../../error' import { JsonTransformer } from '../../utils' import { DidsApi, getKeyFromVerificationMethod } from '../dids' import { @@ -29,32 +29,103 @@ import { W3cPresentation, } from '../vc' -import { PresentationExchangeError } from './PresentationExchangeError' +import { DifPresentationExchangeError } from './DifPresentationExchangeError' +import { DifPresentationExchangeSubmissionLocation } from './models' import { - selectCredentialsForRequest, + getCredentialsForRequest, getSphereonOriginalVerifiableCredential, getSphereonW3cVerifiablePresentation, getW3cVerifiablePresentationInstance, } from './utils' export type ProofStructure = Record>> -export type PresentationDefinition = IPresentationDefinition @injectable() -export class PresentationExchangeService { +export class DifPresentationExchangeService { private pex = new PEX() - public async selectCredentialsForRequest( + public async getCredentialsForRequest( agentContext: AgentContext, - presentationDefinition: PresentationDefinition - ): Promise { + presentationDefinition: DifPresentationExchangeDefinition + ): Promise { const credentialRecords = await this.queryCredentialForPresentationDefinition(agentContext, presentationDefinition) + // FIXME: why are we resolving all created dids here? + // If we want to do this we should extract all dids from the credential records and only + // fetch the dids for the queried credential records const didsApi = agentContext.dependencyManager.resolve(DidsApi) const didRecords = await didsApi.getCreatedDids() const holderDids = didRecords.map((didRecord) => didRecord.did) - return selectCredentialsForRequest(presentationDefinition, credentialRecords, holderDids) + return getCredentialsForRequest(presentationDefinition, credentialRecords, holderDids) + } + + /** + * Selects the credentials to use based on the output from `getCredentialsForRequest` + * Use this method if you don't want to manually select the credentials yourself. + */ + public selectCredentialsForRequest( + credentialsForRequest: DifPexCredentialsForRequest + ): DifPexInputDescriptorToCredentials { + if (!credentialsForRequest.areRequirementsSatisfied) { + throw new AriesFrameworkError('Could not find the required credentials for the presentation submission') + } + + const credentials: DifPexInputDescriptorToCredentials = {} + + for (const requirement of credentialsForRequest.requirements) { + for (const submission of requirement.submissionEntry) { + if (!credentials[submission.inputDescriptorId]) { + credentials[submission.inputDescriptorId] = [] + } + + // We pick the first matching VC if we are auto-selecting + credentials[submission.inputDescriptorId].push(submission.verifiableCredentials[0].credential) + } + } + + return credentials + } + + public validatePresentationDefinition(presentationDefinition: DifPresentationExchangeDefinition) { + const validation = PEX.validateDefinition(presentationDefinition) + const errorMessages = this.formatValidated(validation) + if (errorMessages.length > 0) { + throw new DifPresentationExchangeError(`Invalid presentation definition`, { additionalMessages: errorMessages }) + } + } + + public validatePresentationSubmission(presentationSubmission: DifPresentationExchangeSubmission) { + const validation = PEX.validateSubmission(presentationSubmission) + const errorMessages = this.formatValidated(validation) + if (errorMessages.length > 0) { + throw new DifPresentationExchangeError(`Invalid presentation submission`, { additionalMessages: errorMessages }) + } + } + + public validatePresentation( + presentationDefinition: DifPresentationExchangeDefinition, + presentation: W3cVerifiablePresentation + ) { + const { errors } = this.pex.evaluatePresentation( + presentationDefinition, + presentation.encoded as OriginalVerifiablePresentation + ) + + if (errors) { + const errorMessages = this.formatValidated(errors as Validated) + if (errorMessages.length > 0) { + throw new DifPresentationExchangeError(`Invalid presentation`, { additionalMessages: errorMessages }) + } + } + } + + private formatValidated(v: Validated) { + const validated = Array.isArray(v) ? v : [v] + return validated + .filter((r) => r.tag === Status.ERROR) + .map((r) => r.message) + .filter((r): r is string => Boolean(r)) } /** @@ -63,17 +134,17 @@ export class PresentationExchangeService { */ private async queryCredentialForPresentationDefinition( agentContext: AgentContext, - presentationDefinition: PresentationDefinition + presentationDefinition: DifPresentationExchangeDefinition ): Promise> { const w3cCredentialRepository = agentContext.dependencyManager.resolve(W3cCredentialRepository) const query: Array> = [] const presentationDefinitionVersion = PEX.definitionVersionDiscovery(presentationDefinition) if (!presentationDefinitionVersion.version) { - throw new PresentationExchangeError( - `Unable to determine the Presentation Exchange version from the presentation definition. ${ - presentationDefinitionVersion.error ?? 'Unknown error' - }` + throw new DifPresentationExchangeError( + `Unable to determine the Presentation Exchange version from the presentation definition + `, + presentationDefinitionVersion.error ? { additionalMessages: [presentationDefinitionVersion.error] } : {} ) } @@ -93,16 +164,19 @@ export class PresentationExchangeService { // For now we retrieve ALL credentials, as we did the same for V1 with JWT credentials. We probably need // to find some way to do initial filtering, hopefully if there's a filter on the `type` field or something. } else { - throw new PresentationExchangeError( + throw new DifPresentationExchangeError( `Unsupported presentation definition version ${presentationDefinitionVersion.version as unknown as string}` ) } // query the wallet ourselves first to avoid the need to query the pex library for all // credentials for every proof request - const credentialRecords = await w3cCredentialRepository.findByQuery(agentContext, { - $or: query, - }) + const credentialRecords = + query.length > 0 + ? await w3cCredentialRepository.findByQuery(agentContext, { + $or: query, + }) + : await w3cCredentialRepository.getAll(agentContext) return credentialRecords } @@ -122,7 +196,7 @@ export class PresentationExchangeService { } private getPresentationFormat( - presentationDefinition: PresentationDefinition, + presentationDefinition: DifPresentationExchangeDefinition, credentials: Array ): ClaimFormat.JwtVp | ClaimFormat.LdpVp { const allCredentialsAreJwtVc = credentials?.every((c) => typeof c === 'string') @@ -149,7 +223,7 @@ export class PresentationExchangeService { ) { return ClaimFormat.LdpVp } else { - throw new PresentationExchangeError( + throw new DifPresentationExchangeError( 'No suitable presentation format found for the given presentation definition, and credentials' ) } @@ -158,14 +232,18 @@ export class PresentationExchangeService { public async createPresentation( agentContext: AgentContext, options: { - credentialsForInputDescriptor: InputDescriptorToCredentials - presentationDefinition: PresentationDefinition + credentialsForInputDescriptor: DifPexInputDescriptorToCredentials + presentationDefinition: DifPresentationExchangeDefinition + /** + * Defaults to {@link DifPresentationExchangeSubmissionLocation.PRESENTATION} + */ + presentationSubmissionLocation?: DifPresentationExchangeSubmissionLocation challenge?: string domain?: string nonce?: string } ) { - const { presentationDefinition, challenge, nonce, domain } = options + const { presentationDefinition, challenge, nonce, domain, presentationSubmissionLocation } = options const proofStructure: ProofStructure = {} @@ -173,7 +251,7 @@ export class PresentationExchangeService { credentials.forEach((credential) => { const subjectId = credential.credentialSubjectIds[0] if (!subjectId) { - throw new PresentationExchangeError('Missing required credential subject for creating the presentation.') + throw new DifPresentationExchangeError('Missing required credential subject for creating the presentation.') } this.addCredentialToSubjectInputDescriptor(proofStructure, subjectId, inputDescriptorId, credential) @@ -191,7 +269,7 @@ export class PresentationExchangeService { const verificationMethod = await this.getVerificationMethodForSubjectId(agentContext, subjectId) if (!verificationMethod) { - throw new PresentationExchangeError(`No verification method found for subject id '${subjectId}'.`) + throw new DifPresentationExchangeError(`No verification method found for subject id '${subjectId}'.`) } // We create a presentation for each subject @@ -203,10 +281,10 @@ export class PresentationExchangeService { // Get all the credentials associated with the input descriptors const credentialsForSubject = Object.values(subjectInputDescriptorsToCredentials) - .flatMap((credentials) => credentials) + .flat() .map(getSphereonOriginalVerifiableCredential) - const presentationDefinitionForSubject: PresentationDefinition = { + const presentationDefinitionForSubject: DifPresentationExchangeDefinition = { ...presentationDefinition, input_descriptors: inputDescriptorsForSubject, @@ -226,7 +304,8 @@ export class PresentationExchangeService { holderDID: subjectId, proofOptions: { challenge, domain, nonce }, signatureOptions: { verificationMethod: verificationMethod?.id }, - presentationSubmissionLocation: PresentationSubmissionLocation.EXTERNAL, + presentationSubmissionLocation: + presentationSubmissionLocation ?? DifPresentationExchangeSubmissionLocation.PRESENTATION, } ) @@ -234,19 +313,14 @@ export class PresentationExchangeService { } if (!verifiablePresentationResultsWithFormat[0]) { - throw new PresentationExchangeError('No verifiable presentations created.') - } - - if (!verifiablePresentationResultsWithFormat[0]) { - throw new PresentationExchangeError('No verifiable presentations created.') + throw new DifPresentationExchangeError('No verifiable presentations created') } if (subjectToInputDescriptors.length !== verifiablePresentationResultsWithFormat.length) { - throw new PresentationExchangeError('Invalid amount of verifiable presentations created.') + throw new DifPresentationExchangeError('Invalid amount of verifiable presentations created') } - verifiablePresentationResultsWithFormat[0].verifiablePresentationResult.presentationSubmission - const presentationSubmission: PexPresentationSubmission = { + const presentationSubmission: DifPresentationExchangeSubmission = { id: verifiablePresentationResultsWithFormat[0].verifiablePresentationResult.presentationSubmission.id, definition_id: verifiablePresentationResultsWithFormat[0].verifiablePresentationResult.presentationSubmission.definition_id, @@ -278,7 +352,7 @@ export class PresentationExchangeService { if (suitableAlgorithms) { const possibleAlgorithms = jwk.supportedSignatureAlgorithms.filter((alg) => suitableAlgorithms?.includes(alg)) if (!possibleAlgorithms || possibleAlgorithms.length === 0) { - throw new PresentationExchangeError( + throw new DifPresentationExchangeError( [ `Found no suitable signing algorithm.`, `Algorithms supported by Verification method: ${jwk.supportedSignatureAlgorithms.join(', ')}`, @@ -289,7 +363,7 @@ export class PresentationExchangeService { } const alg = jwk.supportedSignatureAlgorithms[0] - if (!alg) throw new PresentationExchangeError(`No supported algs for key type: ${key.keyType}`) + if (!alg) throw new DifPresentationExchangeError(`No supported algs for key type: ${key.keyType}`) return alg } @@ -311,14 +385,14 @@ export class PresentationExchangeService { algorithmsSatisfyingDescriptors.length > 0 && algorithmsSatisfyingPdAndDescriptorRestrictions.length === 0 ) { - throw new PresentationExchangeError( - `No signature algorithm found for satisfying restrictions of the presentation definition and input descriptors.` + throw new DifPresentationExchangeError( + `No signature algorithm found for satisfying restrictions of the presentation definition and input descriptors` ) } if (allDescriptorAlgorithms.length > 0 && algorithmsSatisfyingDescriptors.length === 0) { - throw new PresentationExchangeError( - `No signature algorithm found for satisfying restrictions of the input descriptors.` + throw new DifPresentationExchangeError( + `No signature algorithm found for satisfying restrictions of the input descriptors` ) } @@ -335,7 +409,7 @@ export class PresentationExchangeService { } private getSigningAlgorithmForJwtVc( - presentationDefinition: PresentationDefinition, + presentationDefinition: DifPresentationExchangeDefinitionV1 | DifPresentationExchangeDefinitionV2, verificationMethod: VerificationMethod ) { const algorithmsSatisfyingDefinition = presentationDefinition.format?.jwt_vc?.alg ?? [] @@ -354,7 +428,7 @@ export class PresentationExchangeService { private getProofTypeForLdpVc( agentContext: AgentContext, - presentationDefinition: PresentationDefinition, + presentationDefinition: DifPresentationExchangeDefinitionV1 | DifPresentationExchangeDefinitionV2, verificationMethod: VerificationMethod ) { const algorithmsSatisfyingDefinition = presentationDefinition.format?.ldp_vc?.proof_type ?? [] @@ -373,14 +447,14 @@ export class PresentationExchangeService { const supportedSignatureSuite = signatureSuiteRegistry.getByVerificationMethodType(verificationMethod.type) if (!supportedSignatureSuite) { - throw new PresentationExchangeError( - `Couldn't find a supported signature suite for the given verification method type '${verificationMethod.type}'.` + throw new DifPresentationExchangeError( + `Couldn't find a supported signature suite for the given verification method type '${verificationMethod.type}'` ) } if (suitableSignatureSuites) { if (suitableSignatureSuites.includes(supportedSignatureSuite.proofType) === false) { - throw new PresentationExchangeError( + throw new DifPresentationExchangeError( [ 'No possible signature suite found for the given verification method.', `Verification method type: ${verificationMethod.type}`, @@ -410,21 +484,18 @@ export class PresentationExchangeService { const { verificationMethod: verificationMethodId } = options.signatureOptions ?? {} if (verificationMethodId && verificationMethodId !== verificationMethod.id) { - throw new PresentationExchangeError( - `Verification method from signing options ${verificationMethodId} does not match verification method ${verificationMethod.id}.` + throw new DifPresentationExchangeError( + `Verification method from signing options ${verificationMethodId} does not match verification method ${verificationMethod.id}` ) } - // Clients MUST ignore any presentation_submission element included inside a Verifiable Presentation. - const presentationToSign = { ...presentationJson, presentation_submission: undefined } - let signedPresentation: W3cVerifiablePresentation if (vpFormat === 'jwt_vp') { signedPresentation = await w3cCredentialService.signPresentation(agentContext, { format: ClaimFormat.JwtVp, alg: this.getSigningAlgorithmForJwtVc(presentationDefinition, verificationMethod), verificationMethod: verificationMethod.id, - presentation: JsonTransformer.fromJSON(presentationToSign, W3cPresentation), + presentation: JsonTransformer.fromJSON(presentationJson, W3cPresentation), challenge: challenge ?? nonce ?? (await agentContext.wallet.generateNonce()), domain, }) @@ -434,13 +505,13 @@ export class PresentationExchangeService { proofType: this.getProofTypeForLdpVc(agentContext, presentationDefinition, verificationMethod), proofPurpose: 'authentication', verificationMethod: verificationMethod.id, - presentation: JsonTransformer.fromJSON(presentationToSign, W3cPresentation), + presentation: JsonTransformer.fromJSON(presentationJson, W3cPresentation), challenge: challenge ?? nonce ?? (await agentContext.wallet.generateNonce()), domain, }) } else { - throw new PresentationExchangeError( - `Only JWT credentials or JSONLD credentials are supported for a single presentation.` + throw new DifPresentationExchangeError( + `Only JWT credentials or JSONLD credentials are supported for a single presentation` ) } @@ -452,7 +523,7 @@ export class PresentationExchangeService { const didsApi = agentContext.dependencyManager.resolve(DidsApi) if (!subjectId.startsWith('did:')) { - throw new PresentationExchangeError( + throw new DifPresentationExchangeError( `Only dids are supported as credentialSubject id. ${subjectId} is not a valid did` ) } @@ -460,7 +531,7 @@ export class PresentationExchangeService { const didDocument = await didsApi.resolveDidDocument(subjectId) if (!didDocument.authentication || didDocument.authentication.length === 0) { - throw new PresentationExchangeError( + throw new DifPresentationExchangeError( `No authentication verificationMethods found for did ${subjectId} in did document` ) } diff --git a/packages/core/src/modules/dif-presentation-exchange/index.ts b/packages/core/src/modules/dif-presentation-exchange/index.ts new file mode 100644 index 0000000000..4f4e4b3923 --- /dev/null +++ b/packages/core/src/modules/dif-presentation-exchange/index.ts @@ -0,0 +1,4 @@ +export * from './DifPresentationExchangeError' +export * from './DifPresentationExchangeModule' +export * from './DifPresentationExchangeService' +export * from './models' diff --git a/packages/core/src/modules/presentation-exchange/models/PresentationSubmission.ts b/packages/core/src/modules/dif-presentation-exchange/models/DifPexCredentialsForRequest.ts similarity index 87% rename from packages/core/src/modules/presentation-exchange/models/PresentationSubmission.ts rename to packages/core/src/modules/dif-presentation-exchange/models/DifPexCredentialsForRequest.ts index 309cb93c62..ec2e83d17e 100644 --- a/packages/core/src/modules/presentation-exchange/models/PresentationSubmission.ts +++ b/packages/core/src/modules/dif-presentation-exchange/models/DifPexCredentialsForRequest.ts @@ -1,6 +1,6 @@ import type { W3cCredentialRecord, W3cVerifiableCredential } from '../../vc' -export interface PresentationSubmission { +export interface DifPexCredentialsForRequest { /** * Whether all requirements have been satisfied by the credentials in the wallet. */ @@ -16,7 +16,7 @@ export interface PresentationSubmission { * combinations that are possible. The structure doesn't include all possible combinations yet that * could satisfy a presentation definition. */ - requirements: PresentationSubmissionRequirement[] + requirements: DifPexCredentialsForRequestRequirement[] /** * Name of the presentation definition @@ -36,7 +36,7 @@ export interface PresentationSubmission { * * Each submission represents a input descriptor. */ -export interface PresentationSubmissionRequirement { +export interface DifPexCredentialsForRequestRequirement { /** * Whether the requirement is satisfied. * @@ -56,7 +56,7 @@ export interface PresentationSubmissionRequirement { purpose?: string /** - * Array of objects, where each entry contains a credential that will be part + * Array of objects, where each entry contains one or more credentials that will be part * of the submission. * * NOTE: if the `isRequirementSatisfied` is `false` the submission list will @@ -66,7 +66,7 @@ export interface PresentationSubmissionRequirement { * `isRequirementSatisfied` is `false`, make sure to check the `needsCount` value * to see how many of those submissions needed. */ - submissionEntry: SubmissionEntry[] + submissionEntry: DifPexCredentialsForRequestSubmissionEntry[] /** * The number of submission entries that are needed to fulfill the requirement. @@ -88,7 +88,7 @@ export interface PresentationSubmissionRequirement { * A submission entry that satisfies a specific input descriptor from the * presentation definition. */ -export interface SubmissionEntry { +export interface DifPexCredentialsForRequestSubmissionEntry { /** * The id of the input descriptor */ @@ -116,4 +116,4 @@ export interface SubmissionEntry { /** * Mapping of selected credentials for an input descriptor */ -export type InputDescriptorToCredentials = Record> +export type DifPexInputDescriptorToCredentials = Record> diff --git a/packages/core/src/modules/dif-presentation-exchange/models/index.ts b/packages/core/src/modules/dif-presentation-exchange/models/index.ts new file mode 100644 index 0000000000..01ce9d6767 --- /dev/null +++ b/packages/core/src/modules/dif-presentation-exchange/models/index.ts @@ -0,0 +1,11 @@ +export * from './DifPexCredentialsForRequest' +import type { PresentationDefinitionV1, PresentationDefinitionV2, PresentationSubmission } from '@sphereon/pex-models' + +import { PresentationSubmissionLocation } from '@sphereon/pex' + +// Re-export some types from sphereon library, but under more explicit names +export type DifPresentationExchangeDefinition = PresentationDefinitionV1 | PresentationDefinitionV2 +export type DifPresentationExchangeDefinitionV1 = PresentationDefinitionV1 +export type DifPresentationExchangeDefinitionV2 = PresentationDefinitionV2 +export type DifPresentationExchangeSubmission = PresentationSubmission +export { PresentationSubmissionLocation as DifPresentationExchangeSubmissionLocation } diff --git a/packages/core/src/modules/presentation-exchange/utils/credentialSelection.ts b/packages/core/src/modules/dif-presentation-exchange/utils/credentialSelection.ts similarity index 84% rename from packages/core/src/modules/presentation-exchange/utils/credentialSelection.ts rename to packages/core/src/modules/dif-presentation-exchange/utils/credentialSelection.ts index fdec050b9e..1fca34b943 100644 --- a/packages/core/src/modules/presentation-exchange/utils/credentialSelection.ts +++ b/packages/core/src/modules/dif-presentation-exchange/utils/credentialSelection.ts @@ -1,5 +1,9 @@ import type { W3cCredentialRecord } from '../../vc' -import type { PresentationSubmission, PresentationSubmissionRequirement, SubmissionEntry } from '../models' +import type { + DifPexCredentialsForRequest, + DifPexCredentialsForRequestRequirement, + DifPexCredentialsForRequestSubmissionEntry, +} from '../models' import type { IPresentationDefinition, SelectResults, SubmissionRequirementMatch } from '@sphereon/pex' import type { InputDescriptorV1, InputDescriptorV2, SubmissionRequirement } from '@sphereon/pex-models' @@ -7,23 +11,24 @@ import { PEX } from '@sphereon/pex' import { Rules } from '@sphereon/pex-models' import { default as jp } from 'jsonpath' -import { PresentationExchangeError } from '../PresentationExchangeError' +import { deepEquality } from '../../../utils' +import { DifPresentationExchangeError } from '../DifPresentationExchangeError' import { getSphereonOriginalVerifiableCredential } from './transform' -export async function selectCredentialsForRequest( +export async function getCredentialsForRequest( presentationDefinition: IPresentationDefinition, credentialRecords: Array, holderDIDs: Array -): Promise { - const encodedCredentials = credentialRecords.map((c) => getSphereonOriginalVerifiableCredential(c.credential)) - +): Promise { if (!presentationDefinition) { - throw new PresentationExchangeError('Presentation Definition is required to select credentials for submission.') + throw new DifPresentationExchangeError('Presentation Definition is required to select credentials for submission.') } const pex = new PEX() + const encodedCredentials = credentialRecords.map((c) => getSphereonOriginalVerifiableCredential(c.credential)) + // FIXME: there is a function for this in the VP library, but it is not usable atm const selectResultsRaw = pex.selectFrom(presentationDefinition, encodedCredentials, { holderDIDs, @@ -36,17 +41,20 @@ export async function selectCredentialsForRequest( ...selectResultsRaw, // Map the encoded credential to their respective w3c credential record verifiableCredential: selectResultsRaw.verifiableCredential?.map((encoded) => { - const credentialIndex = encodedCredentials.indexOf(encoded) - const credentialRecord = credentialRecords[credentialIndex] + const credentialRecord = credentialRecords.find((record) => { + const originalVc = getSphereonOriginalVerifiableCredential(record.credential) + return deepEquality(originalVc, encoded) + }) + if (!credentialRecord) { - throw new PresentationExchangeError('Unable to find credential in credential records.') + throw new DifPresentationExchangeError('Unable to find credential in credential records.') } return credentialRecord }), } - const presentationSubmission: PresentationSubmission = { + const presentationSubmission: DifPexCredentialsForRequest = { requirements: [], areRequirementsSatisfied: false, name: presentationDefinition.name, @@ -67,7 +75,7 @@ export async function selectCredentialsForRequest( // for now if a request is made that has no required requirements (but only e.g. min: 0, which means we don't need to disclose anything) // I see this more as the fault of the presentation definition, as it should have at least some requirements. if (presentationSubmission.requirements.length === 0) { - throw new PresentationExchangeError( + throw new DifPresentationExchangeError( 'Presentation Definition does not require any credentials. Optional credentials are not included in the presentation submission.' ) } @@ -88,22 +96,22 @@ export async function selectCredentialsForRequest( function getSubmissionRequirements( presentationDefinition: IPresentationDefinition, selectResults: W3cCredentialRecordSelectResults -): Array { - const submissionRequirements: Array = [] +): Array { + const submissionRequirements: Array = [] // There are submission requirements, so we need to select the input_descriptors // based on the submission requirements for (const submissionRequirement of presentationDefinition.submission_requirements ?? []) { // Check: if the submissionRequirement uses `from_nested`, as we don't support this yet if (submissionRequirement.from_nested) { - throw new PresentationExchangeError( + throw new DifPresentationExchangeError( "Presentation definition contains requirement using 'from_nested', which is not supported yet." ) } // Check if there's a 'from'. If not the structure is not as we expect it if (!submissionRequirement.from) { - throw new PresentationExchangeError("Missing 'from' in submission requirement match") + throw new DifPresentationExchangeError("Missing 'from' in submission requirement match") } if (submissionRequirement.rule === Rules.All) { @@ -134,8 +142,8 @@ function getSubmissionRequirements( function getSubmissionRequirementsForAllInputDescriptors( inputDescriptors: Array | Array, selectResults: W3cCredentialRecordSelectResults -): Array { - const submissionRequirements: Array = [] +): Array { + const submissionRequirements: Array = [] for (const inputDescriptor of inputDescriptors) { const submission = getSubmissionForInputDescriptor(inputDescriptor, selectResults) @@ -158,9 +166,9 @@ function getSubmissionRequirementRuleAll( ) { // Check if there's a 'from'. If not the structure is not as we expect it if (!submissionRequirement.from) - throw new PresentationExchangeError("Missing 'from' in submission requirement match.") + throw new DifPresentationExchangeError("Missing 'from' in submission requirement match.") - const selectedSubmission: PresentationSubmissionRequirement = { + const selectedSubmission: DifPexCredentialsForRequestRequirement = { rule: Rules.All, needsCount: 0, name: submissionRequirement.name, @@ -197,10 +205,10 @@ function getSubmissionRequirementRulePick( ) { // Check if there's a 'from'. If not the structure is not as we expect it if (!submissionRequirement.from) { - throw new PresentationExchangeError("Missing 'from' in submission requirement match.") + throw new DifPresentationExchangeError("Missing 'from' in submission requirement match.") } - const selectedSubmission: PresentationSubmissionRequirement = { + const selectedSubmission: DifPexCredentialsForRequestRequirement = { rule: Rules.Pick, needsCount: submissionRequirement.count ?? submissionRequirement.min ?? 1, name: submissionRequirement.name, @@ -211,8 +219,8 @@ function getSubmissionRequirementRulePick( isRequirementSatisfied: false, } - const satisfiedSubmissions: Array = [] - const unsatisfiedSubmissions: Array = [] + const satisfiedSubmissions: Array = [] + const unsatisfiedSubmissions: Array = [] for (const inputDescriptor of presentationDefinition.input_descriptors) { // We only want to get the submission if the input descriptor belongs to the group @@ -250,7 +258,7 @@ function getSubmissionRequirementRulePick( function getSubmissionForInputDescriptor( inputDescriptor: InputDescriptorV1 | InputDescriptorV2, selectResults: W3cCredentialRecordSelectResults -): SubmissionEntry { +): DifPexCredentialsForRequestSubmissionEntry { // https://github.com/Sphereon-Opensource/PEX/issues/116 // If the input descriptor doesn't contain a name, the name of the match will be the id of the input descriptor that satisfied it const matchesForInputDescriptor = selectResults.matches?.filter( @@ -260,7 +268,7 @@ function getSubmissionForInputDescriptor( m.name === inputDescriptor.name ) - const submissionEntry: SubmissionEntry = { + const submissionEntry: DifPexCredentialsForRequestSubmissionEntry = { inputDescriptorId: inputDescriptor.id, name: inputDescriptor.name, purpose: inputDescriptor.purpose, diff --git a/packages/core/src/modules/presentation-exchange/utils/index.ts b/packages/core/src/modules/dif-presentation-exchange/utils/index.ts similarity index 100% rename from packages/core/src/modules/presentation-exchange/utils/index.ts rename to packages/core/src/modules/dif-presentation-exchange/utils/index.ts diff --git a/packages/core/src/modules/presentation-exchange/utils/transform.ts b/packages/core/src/modules/dif-presentation-exchange/utils/transform.ts similarity index 93% rename from packages/core/src/modules/presentation-exchange/utils/transform.ts rename to packages/core/src/modules/dif-presentation-exchange/utils/transform.ts index c857c362db..e4d5f694c9 100644 --- a/packages/core/src/modules/presentation-exchange/utils/transform.ts +++ b/packages/core/src/modules/dif-presentation-exchange/utils/transform.ts @@ -13,7 +13,7 @@ import { W3cJwtVerifiablePresentation, ClaimFormat, } from '../../vc' -import { PresentationExchangeError } from '../PresentationExchangeError' +import { DifPresentationExchangeError } from '../DifPresentationExchangeError' export function getSphereonOriginalVerifiableCredential( w3cVerifiableCredential: W3cVerifiableCredential @@ -23,7 +23,7 @@ export function getSphereonOriginalVerifiableCredential( } else if (w3cVerifiableCredential.claimFormat === ClaimFormat.JwtVc) { return w3cVerifiableCredential.serializedJwt } else { - throw new PresentationExchangeError( + throw new DifPresentationExchangeError( `Unsupported claim format. Only ${ClaimFormat.LdpVc} and ${ClaimFormat.JwtVc} are supported.` ) } @@ -37,7 +37,7 @@ export function getSphereonW3cVerifiableCredential( } else if (w3cVerifiableCredential.claimFormat === ClaimFormat.JwtVc) { return w3cVerifiableCredential.serializedJwt } else { - throw new PresentationExchangeError( + throw new DifPresentationExchangeError( `Unsupported claim format. Only ${ClaimFormat.LdpVc} and ${ClaimFormat.JwtVc} are supported.` ) } @@ -51,7 +51,7 @@ export function getSphereonW3cVerifiablePresentation( } else if (w3cVerifiablePresentation instanceof W3cJwtVerifiablePresentation) { return w3cVerifiablePresentation.serializedJwt } else { - throw new PresentationExchangeError( + throw new DifPresentationExchangeError( `Unsupported claim format. Only ${ClaimFormat.LdpVc} and ${ClaimFormat.JwtVc} are supported.` ) } diff --git a/packages/core/src/modules/presentation-exchange/PresentationExchangeError.ts b/packages/core/src/modules/presentation-exchange/PresentationExchangeError.ts deleted file mode 100644 index e9be720603..0000000000 --- a/packages/core/src/modules/presentation-exchange/PresentationExchangeError.ts +++ /dev/null @@ -1,3 +0,0 @@ -import { AriesFrameworkError } from '../../error' - -export class PresentationExchangeError extends AriesFrameworkError {} diff --git a/packages/core/src/modules/presentation-exchange/PresentationExchangeModule.ts b/packages/core/src/modules/presentation-exchange/PresentationExchangeModule.ts deleted file mode 100644 index dba83cd306..0000000000 --- a/packages/core/src/modules/presentation-exchange/PresentationExchangeModule.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { DependencyManager, Module } from '../../plugins' - -import { AgentConfig } from '../../agent/AgentConfig' - -import { PresentationExchangeService } from './PresentationExchangeService' - -/** - * @public - */ -export class PresentationExchangeModule implements Module { - /** - * Registers the dependencies of the presentation-exchange module on the dependency manager. - */ - public register(dependencyManager: DependencyManager) { - // Warn about experimental module - dependencyManager - .resolve(AgentConfig) - .logger.warn( - "The 'PresentationExchangeModule' module is experimental and could have unexpected breaking changes. When using this module, make sure to use strict versions for all @aries-framework packages." - ) - - // Services - dependencyManager.registerSingleton(PresentationExchangeService) - } -} diff --git a/packages/core/src/modules/presentation-exchange/index.ts b/packages/core/src/modules/presentation-exchange/index.ts deleted file mode 100644 index 0bb3c76aae..0000000000 --- a/packages/core/src/modules/presentation-exchange/index.ts +++ /dev/null @@ -1,4 +0,0 @@ -export * from './PresentationExchangeError' -export * from './PresentationExchangeModule' -export * from './PresentationExchangeService' -export * from './models' diff --git a/packages/core/src/modules/presentation-exchange/models/index.ts b/packages/core/src/modules/presentation-exchange/models/index.ts deleted file mode 100644 index 47247cbbc9..0000000000 --- a/packages/core/src/modules/presentation-exchange/models/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './PresentationSubmission' diff --git a/packages/core/src/modules/proofs/formats/dif-presentation-exchange/DifPresentationExchangeProofFormat.ts b/packages/core/src/modules/proofs/formats/dif-presentation-exchange/DifPresentationExchangeProofFormat.ts new file mode 100644 index 0000000000..ca7e908a76 --- /dev/null +++ b/packages/core/src/modules/proofs/formats/dif-presentation-exchange/DifPresentationExchangeProofFormat.ts @@ -0,0 +1,73 @@ +import type { + DifPexInputDescriptorToCredentials, + DifPexCredentialsForRequest, + DifPresentationExchangeDefinitionV1, +} from '../../../dif-presentation-exchange' +import type { W3cJsonPresentation } from '../../../vc/models/presentation/W3cJsonPresentation' +import type { ProofFormat } from '../ProofFormat' + +export type DifPresentationExchangeProposal = DifPresentationExchangeDefinitionV1 + +export type DifPresentationExchangeRequest = { + options?: { + challenge?: string + domain?: string + } + presentation_definition: DifPresentationExchangeDefinitionV1 +} + +export type DifPresentationExchangePresentation = + | W3cJsonPresentation + // NOTE: this is not spec compliant, as it doesn't describe how to submit + // JWT VPs but to support JWT VPs we also allow the value to be a string + | string + +export interface DifPresentationExchangeProofFormat extends ProofFormat { + formatKey: 'presentationExchange' + + proofFormats: { + createProposal: { + presentationDefinition: DifPresentationExchangeDefinitionV1 + } + + acceptProposal: { + options?: { + challenge?: string + domain?: string + } + } + + createRequest: { + presentationDefinition: DifPresentationExchangeDefinitionV1 + options?: { + challenge?: string + domain?: string + } + } + + acceptRequest: { + credentials?: DifPexInputDescriptorToCredentials + } + + getCredentialsForRequest: { + input: never + // Presentation submission details which the options that are available + output: DifPexCredentialsForRequest + } + + selectCredentialsForRequest: { + input: never + // Input descriptor to credentials specifically details which credentials + // should be used for which input descriptor + output: { + credentials: DifPexInputDescriptorToCredentials + } + } + } + + formatData: { + proposal: DifPresentationExchangeProposal + request: DifPresentationExchangeRequest + presentation: DifPresentationExchangePresentation + } +} 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 new file mode 100644 index 0000000000..3260e806d2 --- /dev/null +++ b/packages/core/src/modules/proofs/formats/dif-presentation-exchange/DifPresentationExchangeProofFormatService.ts @@ -0,0 +1,373 @@ +import type { + DifPresentationExchangePresentation, + DifPresentationExchangeProofFormat, + DifPresentationExchangeProposal, + DifPresentationExchangeRequest, +} from './DifPresentationExchangeProofFormat' +import type { AgentContext } from '../../../../agent' +import type { JsonValue } from '../../../../types' +import type { DifPexInputDescriptorToCredentials } from '../../../dif-presentation-exchange' +import type { W3cVerifiablePresentation, W3cVerifyPresentationResult } from '../../../vc' +import type { W3cJsonPresentation } from '../../../vc/models/presentation/W3cJsonPresentation' +import type { ProofFormatService } from '../ProofFormatService' +import type { + ProofFormatCreateProposalOptions, + ProofFormatCreateReturn, + ProofFormatProcessOptions, + ProofFormatAcceptProposalOptions, + FormatCreateRequestOptions, + ProofFormatAcceptRequestOptions, + ProofFormatProcessPresentationOptions, + ProofFormatGetCredentialsForRequestOptions, + ProofFormatSelectCredentialsForRequestOptions, + ProofFormatAutoRespondProposalOptions, + ProofFormatAutoRespondRequestOptions, + ProofFormatAutoRespondPresentationOptions, +} from '../ProofFormatServiceOptions' + +import { Attachment, AttachmentData } from '../../../../decorators/attachment/Attachment' +import { AriesFrameworkError } from '../../../../error' +import { deepEquality, JsonTransformer } from '../../../../utils' +import { DifPresentationExchangeService } from '../../../dif-presentation-exchange' +import { + W3cCredentialService, + ClaimFormat, + W3cJsonLdVerifiablePresentation, + W3cJwtVerifiablePresentation, +} from '../../../vc' +import { ProofFormatSpec } from '../../models' + +const PRESENTATION_EXCHANGE_PRESENTATION_PROPOSAL = 'dif/presentation-exchange/definitions@v1.0' +const PRESENTATION_EXCHANGE_PRESENTATION_REQUEST = 'dif/presentation-exchange/definitions@v1.0' +const PRESENTATION_EXCHANGE_PRESENTATION = 'dif/presentation-exchange/submission@v1.0' + +export class PresentationExchangeProofFormatService implements ProofFormatService { + public readonly formatKey = 'presentationExchange' as const + + private presentationExchangeService(agentContext: AgentContext) { + if (!agentContext.dependencyManager.isRegistered(DifPresentationExchangeService)) { + throw new AriesFrameworkError( + 'DifPresentationExchangeService is not registered on the Agent. Please provide the PresentationExchangeModule as a module on the agent' + ) + } + + return agentContext.dependencyManager.resolve(DifPresentationExchangeService) + } + + public supportsFormat(formatIdentifier: string): boolean { + return [ + PRESENTATION_EXCHANGE_PRESENTATION_PROPOSAL, + PRESENTATION_EXCHANGE_PRESENTATION_REQUEST, + PRESENTATION_EXCHANGE_PRESENTATION, + ].includes(formatIdentifier) + } + + public async createProposal( + agentContext: AgentContext, + { proofFormats, attachmentId }: ProofFormatCreateProposalOptions + ): Promise { + const ps = this.presentationExchangeService(agentContext) + + const pexFormat = proofFormats.presentationExchange + if (!pexFormat) { + throw new AriesFrameworkError('Missing Presentation Exchange format in create proposal attachment format') + } + + const { presentationDefinition } = pexFormat + + ps.validatePresentationDefinition(presentationDefinition) + + const format = new ProofFormatSpec({ format: PRESENTATION_EXCHANGE_PRESENTATION_PROPOSAL, attachmentId }) + + const attachment = this.getFormatData(presentationDefinition, format.attachmentId) + + return { format, attachment } + } + + public async processProposal(agentContext: AgentContext, { attachment }: ProofFormatProcessOptions): Promise { + const ps = this.presentationExchangeService(agentContext) + const proposal = attachment.getDataAsJson() + ps.validatePresentationDefinition(proposal) + } + + public async acceptProposal( + agentContext: AgentContext, + { + attachmentId, + proposalAttachment, + proofFormats, + }: ProofFormatAcceptProposalOptions + ): Promise { + const ps = this.presentationExchangeService(agentContext) + + const presentationExchangeFormat = proofFormats?.presentationExchange + + const format = new ProofFormatSpec({ + format: PRESENTATION_EXCHANGE_PRESENTATION_REQUEST, + attachmentId, + }) + + const presentationDefinition = proposalAttachment.getDataAsJson() + ps.validatePresentationDefinition(presentationDefinition) + + const attachment = this.getFormatData( + { + presentation_definition: presentationDefinition, + options: { + // NOTE: we always want to include a challenge to prevent replay attacks + challenge: presentationExchangeFormat?.options?.challenge ?? (await agentContext.wallet.generateNonce()), + domain: presentationExchangeFormat?.options?.domain, + }, + } satisfies DifPresentationExchangeRequest, + format.attachmentId + ) + + return { format, attachment } + } + + public async createRequest( + agentContext: AgentContext, + { attachmentId, proofFormats }: FormatCreateRequestOptions + ): Promise { + const ps = this.presentationExchangeService(agentContext) + + const presentationExchangeFormat = proofFormats.presentationExchange + if (!presentationExchangeFormat) { + throw Error('Missing presentation exchange format in create request attachment format') + } + + const { presentationDefinition, options } = presentationExchangeFormat + + ps.validatePresentationDefinition(presentationDefinition) + + const format = new ProofFormatSpec({ + format: PRESENTATION_EXCHANGE_PRESENTATION_REQUEST, + attachmentId, + }) + + const attachment = this.getFormatData( + { + presentation_definition: presentationDefinition, + options: { + // NOTE: we always want to include a challenge to prevent replay attacks + challenge: options?.challenge ?? (await agentContext.wallet.generateNonce()), + domain: options?.domain, + }, + } satisfies DifPresentationExchangeRequest, + format.attachmentId + ) + + return { attachment, format } + } + + public async processRequest(agentContext: AgentContext, { attachment }: ProofFormatProcessOptions): Promise { + const ps = this.presentationExchangeService(agentContext) + const { presentation_definition: presentationDefinition } = + attachment.getDataAsJson() + ps.validatePresentationDefinition(presentationDefinition) + } + + public async acceptRequest( + agentContext: AgentContext, + { + attachmentId, + requestAttachment, + proofFormats, + }: ProofFormatAcceptRequestOptions + ): Promise { + const ps = this.presentationExchangeService(agentContext) + + const format = new ProofFormatSpec({ + format: PRESENTATION_EXCHANGE_PRESENTATION, + attachmentId, + }) + + const { presentation_definition: presentationDefinition, options } = + requestAttachment.getDataAsJson() + + const credentials: DifPexInputDescriptorToCredentials = proofFormats?.presentationExchange?.credentials ?? {} + if (Object.keys(credentials).length === 0) { + const { areRequirementsSatisfied, requirements } = await ps.getCredentialsForRequest( + agentContext, + presentationDefinition + ) + + if (!areRequirementsSatisfied) { + throw new AriesFrameworkError('Requirements of the presentation definition could not be satisfied') + } + + requirements.forEach((r) => { + r.submissionEntry.forEach((r) => { + credentials[r.inputDescriptorId] = r.verifiableCredentials.map((c) => c.credential) + }) + }) + } + + const presentation = await ps.createPresentation(agentContext, { + presentationDefinition, + credentialsForInputDescriptor: credentials, + challenge: options?.challenge, + domain: options?.domain, + }) + + if (presentation.verifiablePresentations.length > 1) { + throw new AriesFrameworkError('Invalid amount of verifiable presentations. Only one is allowed.') + } + + const firstPresentation = presentation.verifiablePresentations[0] + const attachmentData = firstPresentation.encoded as DifPresentationExchangePresentation + const attachment = this.getFormatData(attachmentData, format.attachmentId) + + return { attachment, format } + } + + public async processPresentation( + agentContext: AgentContext, + { requestAttachment, attachment }: ProofFormatProcessPresentationOptions + ): Promise { + const ps = this.presentationExchangeService(agentContext) + const w3cCredentialService = agentContext.dependencyManager.resolve(W3cCredentialService) + + const request = requestAttachment.getDataAsJson() + const presentation = attachment.getDataAsJson() + let parsedPresentation: W3cVerifiablePresentation + let jsonPresentation: W3cJsonPresentation + + // TODO: we should probably move this transformation logic into the VC module, so it + // can be reused in AFJ when we need to go from encoded -> parsed + if (typeof presentation === 'string') { + parsedPresentation = W3cJwtVerifiablePresentation.fromSerializedJwt(presentation) + jsonPresentation = parsedPresentation.presentation.toJSON() + } else { + parsedPresentation = JsonTransformer.fromJSON(presentation, W3cJsonLdVerifiablePresentation) + jsonPresentation = parsedPresentation.toJSON() + } + + if (!jsonPresentation.presentation_submission) { + agentContext.config.logger.error( + 'Received presentation in PEX proof format without presentation submission. This should not happen.' + ) + return false + } + + if (!request.options?.challenge) { + agentContext.config.logger.error( + 'Received presentation in PEX proof format without challenge. This should not happen.' + ) + return false + } + + try { + ps.validatePresentationDefinition(request.presentation_definition) + ps.validatePresentationSubmission(jsonPresentation.presentation_submission) + ps.validatePresentation(request.presentation_definition, parsedPresentation) + + let verificationResult: W3cVerifyPresentationResult + + // FIXME: for some reason it won't accept the input if it doesn't know + // whether it's a JWT or JSON-LD VP even though the input is the same. + // Not sure how to fix + if (parsedPresentation.claimFormat === ClaimFormat.JwtVp) { + verificationResult = await w3cCredentialService.verifyPresentation(agentContext, { + presentation: parsedPresentation, + challenge: request.options.challenge, + domain: request.options.domain, + }) + } else { + verificationResult = await w3cCredentialService.verifyPresentation(agentContext, { + presentation: parsedPresentation, + challenge: request.options.challenge, + domain: request.options.domain, + }) + } + + if (!verificationResult.isValid) { + agentContext.config.logger.error( + `Received presentation in PEX proof format that could not be verified: ${verificationResult.error}`, + { verificationResult } + ) + return false + } + + return true + } catch (e) { + agentContext.config.logger.error(`Failed to verify presentation in PEX proof format service: ${e.message}`, { + cause: e, + }) + return false + } + } + + public async getCredentialsForRequest( + agentContext: AgentContext, + { requestAttachment }: ProofFormatGetCredentialsForRequestOptions + ) { + const ps = this.presentationExchangeService(agentContext) + const { presentation_definition: presentationDefinition } = + requestAttachment.getDataAsJson() + + ps.validatePresentationDefinition(presentationDefinition) + + const presentationSubmission = await ps.getCredentialsForRequest(agentContext, presentationDefinition) + return presentationSubmission + } + + public async selectCredentialsForRequest( + agentContext: AgentContext, + { requestAttachment }: ProofFormatSelectCredentialsForRequestOptions + ) { + const ps = this.presentationExchangeService(agentContext) + const { presentation_definition: presentationDefinition } = + requestAttachment.getDataAsJson() + + const credentialsForRequest = await ps.getCredentialsForRequest(agentContext, presentationDefinition) + return { credentials: ps.selectCredentialsForRequest(credentialsForRequest) } + } + + public async shouldAutoRespondToProposal( + _agentContext: AgentContext, + { requestAttachment, proposalAttachment }: ProofFormatAutoRespondProposalOptions + ): Promise { + const proposalData = proposalAttachment.getDataAsJson() + const requestData = requestAttachment.getDataAsJson() + + return deepEquality(requestData.presentation_definition, proposalData) + } + + public async shouldAutoRespondToRequest( + _agentContext: AgentContext, + { requestAttachment, proposalAttachment }: ProofFormatAutoRespondRequestOptions + ): Promise { + const proposalData = proposalAttachment.getDataAsJson() + const requestData = requestAttachment.getDataAsJson() + + return deepEquality(requestData.presentation_definition, proposalData) + } + + /** + * + * The presentation is already verified in processPresentation, so we can just return true here. + * It's only an ack, so it's just that we received the presentation. + * + */ + public async shouldAutoRespondToPresentation( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _agentContext: AgentContext, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + _options: ProofFormatAutoRespondPresentationOptions + ): Promise { + return true + } + + private getFormatData(data: unknown, id: string): Attachment { + const attachment = new Attachment({ + id, + mimeType: 'application/json', + data: new AttachmentData({ + json: data as JsonValue, + }), + }) + + return attachment + } +} diff --git a/packages/core/src/modules/proofs/formats/dif-presentation-exchange/__tests__/PresentationExchangeProofFormatService.test.ts b/packages/core/src/modules/proofs/formats/dif-presentation-exchange/__tests__/PresentationExchangeProofFormatService.test.ts new file mode 100644 index 0000000000..316927aaf8 --- /dev/null +++ b/packages/core/src/modules/proofs/formats/dif-presentation-exchange/__tests__/PresentationExchangeProofFormatService.test.ts @@ -0,0 +1,204 @@ +import type { DifPresentationExchangeDefinitionV1 } from '../../../../dif-presentation-exchange' +import type { ProofFormatService } from '../../ProofFormatService' +import type { DifPresentationExchangeProofFormat } from '../DifPresentationExchangeProofFormat' + +import { PresentationSubmissionLocation } from '@sphereon/pex' + +import { getIndySdkModules } from '../../../../../../../indy-sdk/tests/setupIndySdkModule' +import { agentDependencies, getAgentConfig } from '../../../../../../tests' +import { Agent } from '../../../../../agent/Agent' +import { DifPresentationExchangeModule, DifPresentationExchangeService } from '../../../../dif-presentation-exchange' +import { + W3cJsonLdVerifiableCredential, + W3cCredentialRecord, + W3cCredentialRepository, + CREDENTIALS_CONTEXT_V1_URL, + W3cJsonLdVerifiablePresentation, +} from '../../../../vc' +import { ProofsModule } from '../../../ProofsModule' +import { ProofState } from '../../../models' +import { V2ProofProtocol } from '../../../protocol' +import { ProofExchangeRecord } from '../../../repository' +import { PresentationExchangeProofFormatService } from '../DifPresentationExchangeProofFormatService' + +const mockProofRecord = () => + new ProofExchangeRecord({ + state: ProofState.ProposalSent, + threadId: 'add7e1a0-109e-4f37-9caa-cfd0fcdfe540', + protocolVersion: 'v2', + }) + +const mockPresentationDefinition = (): DifPresentationExchangeDefinitionV1 => ({ + id: '32f54163-7166-48f1-93d8-ff217bdb0653', + input_descriptors: [ + { + schema: [{ uri: 'https://www.w3.org/2018/credentials/examples/v1' }], + id: 'wa_driver_license', + name: 'Washington State Business License', + purpose: 'We can only allow licensed Washington State business representatives into the WA Business Conference', + constraints: { + fields: [ + { + path: ['$.credentialSubject.id'], + }, + ], + }, + }, + ], +}) + +const mockCredentialRecord = new W3cCredentialRecord({ + tags: {}, + credential: new W3cJsonLdVerifiableCredential({ + id: 'did:some:id', + context: [CREDENTIALS_CONTEXT_V1_URL, 'https://www.w3.org/2018/credentials/examples/v1'], + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + issuer: 'did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL', + issuanceDate: '2017-10-22T12:23:48Z', + credentialSubject: { + id: 'did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL', + }, + proof: { + type: 'Ed25519Signature2020', + created: '2021-11-13T18:19:39Z', + verificationMethod: 'https://example.edu/issuers/14#key-1', + proofPurpose: 'assertionMethod', + proofValue: 'z58DAdFfa9SkqZMVPxAQpic7ndSayn1PzZs6ZjWp1CktyGesjuTSwRdoWhAfGFCF5bppETSTojQCrfFPP2oumHKtz', + }, + }), +}) + +const presentationSubmission = { id: 'did:id', definition_id: 'my-id', descriptor_map: [] } +jest.spyOn(W3cCredentialRepository.prototype, 'findByQuery').mockResolvedValue([mockCredentialRecord]) +jest.spyOn(DifPresentationExchangeService.prototype, 'createPresentation').mockResolvedValue({ + presentationSubmission, + verifiablePresentations: [ + new W3cJsonLdVerifiablePresentation({ + verifiableCredential: [mockCredentialRecord.credential], + proof: { + type: 'Ed25519Signature2020', + created: '2021-11-13T18:19:39Z', + verificationMethod: 'https://example.edu/issuers/14#key-1', + proofPurpose: 'assertionMethod', + proofValue: 'z58DAdFfa9SkqZMVPxAQpic7ndSayn1PzZs6ZjWp1CktyGesjuTSwRdoWhAfGFCF5bppETSTojQCrfFPP2oumHKtz', + }, + }), + ], + presentationSubmissionLocation: PresentationSubmissionLocation.PRESENTATION, +}) + +describe('Presentation Exchange ProofFormatService', () => { + let pexFormatService: ProofFormatService + let agent: Agent + + beforeAll(async () => { + agent = new Agent({ + config: getAgentConfig('PresentationExchangeProofFormatService'), + modules: { + someModule: new DifPresentationExchangeModule(), + proofs: new ProofsModule({ + proofProtocols: [new V2ProofProtocol({ proofFormats: [new PresentationExchangeProofFormatService()] })], + }), + ...getIndySdkModules(), + }, + dependencies: agentDependencies, + }) + + await agent.initialize() + + pexFormatService = agent.dependencyManager.resolve(PresentationExchangeProofFormatService) + }) + + describe('Create Presentation Exchange Proof Proposal / Request', () => { + test('Creates Presentation Exchange Proposal', async () => { + const presentationDefinition = mockPresentationDefinition() + const { format, attachment } = await pexFormatService.createProposal(agent.context, { + proofRecord: mockProofRecord(), + proofFormats: { presentationExchange: { presentationDefinition } }, + }) + + expect(attachment).toMatchObject({ + id: expect.any(String), + mimeType: 'application/json', + data: { + json: presentationDefinition, + }, + }) + + expect(format).toMatchObject({ + attachmentId: expect.any(String), + format: 'dif/presentation-exchange/definitions@v1.0', + }) + }) + + test('Creates Presentation Exchange Request', async () => { + const presentationDefinition = mockPresentationDefinition() + const { format, attachment } = await pexFormatService.createRequest(agent.context, { + proofRecord: mockProofRecord(), + proofFormats: { presentationExchange: { presentationDefinition } }, + }) + + expect(attachment).toMatchObject({ + id: expect.any(String), + mimeType: 'application/json', + data: { + json: { + options: { + challenge: expect.any(String), + }, + presentation_definition: presentationDefinition, + }, + }, + }) + + expect(format).toMatchObject({ + attachmentId: expect.any(String), + format: 'dif/presentation-exchange/definitions@v1.0', + }) + }) + }) + + describe('Accept Proof Request', () => { + test('Accept a Presentation Exchange Proof Request', async () => { + const presentationDefinition = mockPresentationDefinition() + const { attachment: requestAttachment } = await pexFormatService.createRequest(agent.context, { + proofRecord: mockProofRecord(), + proofFormats: { presentationExchange: { presentationDefinition } }, + }) + + const { attachment, format } = await pexFormatService.acceptRequest(agent.context, { + proofRecord: mockProofRecord(), + requestAttachment, + }) + + expect(attachment).toMatchObject({ + id: expect.any(String), + mimeType: 'application/json', + data: { + json: { + '@context': expect.any(Array), + type: expect.any(Array), + verifiableCredential: [ + { + '@context': expect.any(Array), + id: expect.any(String), + type: expect.any(Array), + issuer: expect.any(String), + issuanceDate: expect.any(String), + credentialSubject: { + id: expect.any(String), + }, + proof: expect.any(Object), + }, + ], + }, + }, + }) + + expect(format).toMatchObject({ + attachmentId: expect.any(String), + format: 'dif/presentation-exchange/submission@v1.0', + }) + }) + }) +}) diff --git a/packages/core/src/modules/proofs/formats/dif-presentation-exchange/index.ts b/packages/core/src/modules/proofs/formats/dif-presentation-exchange/index.ts new file mode 100644 index 0000000000..b8a8c35e4e --- /dev/null +++ b/packages/core/src/modules/proofs/formats/dif-presentation-exchange/index.ts @@ -0,0 +1,2 @@ +export * from './DifPresentationExchangeProofFormat' +export * from './DifPresentationExchangeProofFormatService' diff --git a/packages/core/src/modules/proofs/formats/index.ts b/packages/core/src/modules/proofs/formats/index.ts index a28e77d623..a2cc952c57 100644 --- a/packages/core/src/modules/proofs/formats/index.ts +++ b/packages/core/src/modules/proofs/formats/index.ts @@ -2,6 +2,8 @@ export * from './ProofFormat' export * from './ProofFormatService' export * from './ProofFormatServiceOptions' +export * from './dif-presentation-exchange' + import * as ProofFormatServiceOptions from './ProofFormatServiceOptions' export { ProofFormatServiceOptions } diff --git a/packages/core/src/modules/proofs/protocol/v2/__tests__/fixtures.ts b/packages/core/src/modules/proofs/protocol/v2/__tests__/fixtures.ts new file mode 100644 index 0000000000..0b3d8c39b9 --- /dev/null +++ b/packages/core/src/modules/proofs/protocol/v2/__tests__/fixtures.ts @@ -0,0 +1,13 @@ +import type { InputDescriptorV1 } from '@sphereon/pex-models' + +export const TEST_INPUT_DESCRIPTORS_CITIZENSHIP = { + constraints: { + fields: [ + { + path: ['$.credentialSubject.degree.type'], + }, + ], + }, + id: 'citizenship_input_1', + schema: [{ uri: 'https://www.w3.org/2018/credentials/examples/v1' }], +} satisfies InputDescriptorV1 diff --git a/packages/core/src/modules/proofs/protocol/v2/__tests__/v2-presentation-exchange-presentation.e2e.test.ts b/packages/core/src/modules/proofs/protocol/v2/__tests__/v2-presentation-exchange-presentation.e2e.test.ts new file mode 100644 index 0000000000..264dd8d660 --- /dev/null +++ b/packages/core/src/modules/proofs/protocol/v2/__tests__/v2-presentation-exchange-presentation.e2e.test.ts @@ -0,0 +1,436 @@ +import type { getJsonLdModules } from '../../../../../../tests' +import type { Agent } from '../../../../../agent/Agent' +import type { ProofExchangeRecord } from '../../../repository/ProofExchangeRecord' + +import { waitForCredentialRecord, setupJsonLdTests, waitForProofExchangeRecord } from '../../../../../../tests' +import testLogger from '../../../../../../tests/logger' +import { KeyType } from '../../../../../crypto' +import { DidCommMessageRepository } from '../../../../../storage' +import { TypedArrayEncoder } from '../../../../../utils' +import { AutoAcceptCredential, CredentialState } from '../../../../credentials' +import { CREDENTIALS_CONTEXT_V1_URL } from '../../../../vc' +import { ProofState } from '../../../models/ProofState' +import { V2PresentationMessage, V2RequestPresentationMessage } from '../messages' +import { V2ProposePresentationMessage } from '../messages/V2ProposePresentationMessage' + +import { TEST_INPUT_DESCRIPTORS_CITIZENSHIP } from './fixtures' + +const jsonld = { + credential: { + '@context': [CREDENTIALS_CONTEXT_V1_URL, 'https://www.w3.org/2018/credentials/examples/v1'], + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + issuer: 'did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL', + issuanceDate: '2017-10-22T12:23:48Z', + credentialSubject: { + id: 'did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL', + degree: { + type: 'BachelorDegree', + name: 'Bachelor of Science and Arts', + }, + }, + }, + options: { + proofType: 'Ed25519Signature2018', + proofPurpose: 'assertionMethod', + }, +} + +describe('Present Proof', () => { + let proverAgent: Agent> + let issuerAgent: Agent> + let verifierAgent: Agent> + + let issuerProverConnectionId: string + let proverVerifierConnectionId: string + + let verifierProofExchangeRecord: ProofExchangeRecord + let proverProofExchangeRecord: ProofExchangeRecord + + let didCommMessageRepository: DidCommMessageRepository + + beforeAll(async () => { + testLogger.test('Initializing the agents') + ;({ + holderAgent: proverAgent, + issuerAgent, + verifierAgent, + issuerHolderConnectionId: issuerProverConnectionId, + holderVerifierConnectionId: proverVerifierConnectionId, + } = await setupJsonLdTests({ + holderName: 'presentation exchange prover agent', + issuerName: 'presentation exchange issuer agent', + verifierName: 'presentation exchange verifier agent', + createConnections: true, + autoAcceptCredentials: AutoAcceptCredential.Always, + })) + + await issuerAgent.wallet.createKey({ + privateKey: TypedArrayEncoder.fromString('testseed000000000000000000000001'), + keyType: KeyType.Ed25519, + }) + + await proverAgent.wallet.createKey({ + privateKey: TypedArrayEncoder.fromString('testseed000000000000000000000001'), + keyType: KeyType.Ed25519, + }) + + await issuerAgent.credentials.offerCredential({ + connectionId: issuerProverConnectionId, + protocolVersion: 'v2', + credentialFormats: { jsonld }, + }) + + await waitForCredentialRecord(proverAgent, { state: CredentialState.Done }) + }) + + afterAll(async () => { + testLogger.test('Shutting down both agents') + await proverAgent.shutdown() + await proverAgent.wallet.delete() + await verifierAgent.shutdown() + await verifierAgent.wallet.delete() + }) + + test(`Prover Creates and sends Proof Proposal to a Verifier`, async () => { + testLogger.test('Prover sends proof proposal to a Verifier') + + const verifierPresentationRecordPromise = waitForProofExchangeRecord(verifierAgent, { + state: ProofState.ProposalReceived, + }) + + proverProofExchangeRecord = await proverAgent.proofs.proposeProof({ + connectionId: proverVerifierConnectionId, + protocolVersion: 'v2', + proofFormats: { + presentationExchange: { + presentationDefinition: { + id: 'e950bfe5-d7ec-4303-ad61-6983fb976ac9', + input_descriptors: [TEST_INPUT_DESCRIPTORS_CITIZENSHIP], + }, + }, + }, + comment: 'V2 Presentation Exchange propose proof test', + }) + + testLogger.test('Verifier waits for presentation from the Prover') + verifierProofExchangeRecord = await verifierPresentationRecordPromise + + didCommMessageRepository = proverAgent.dependencyManager.resolve(DidCommMessageRepository) + + const proposal = await didCommMessageRepository.findAgentMessage(verifierAgent.context, { + associatedRecordId: verifierProofExchangeRecord.id, + messageClass: V2ProposePresentationMessage, + }) + + expect(proposal).toMatchObject({ + type: 'https://didcomm.org/present-proof/2.0/propose-presentation', + formats: [ + { + attachmentId: expect.any(String), + format: 'dif/presentation-exchange/definitions@v1.0', + }, + ], + proposalAttachments: [ + { + id: expect.any(String), + mimeType: 'application/json', + data: { + json: { + input_descriptors: expect.any(Array), + }, + }, + }, + ], + id: expect.any(String), + comment: 'V2 Presentation Exchange propose proof test', + }) + expect(verifierProofExchangeRecord.id).not.toBeNull() + expect(verifierProofExchangeRecord).toMatchObject({ + threadId: verifierProofExchangeRecord.threadId, + state: ProofState.ProposalReceived, + protocolVersion: 'v2', + }) + }) + + test(`Verifier accepts the Proposal send by the Prover`, async () => { + const proverPresentationRecordPromise = waitForProofExchangeRecord(proverAgent, { + threadId: verifierProofExchangeRecord.threadId, + state: ProofState.RequestReceived, + }) + + testLogger.test('Verifier accepts presentation proposal from the Prover') + verifierProofExchangeRecord = await verifierAgent.proofs.acceptProposal({ + proofRecordId: verifierProofExchangeRecord.id, + }) + + testLogger.test('Prover waits for proof request from the Verifier') + proverProofExchangeRecord = await proverPresentationRecordPromise + + didCommMessageRepository = proverAgent.dependencyManager.resolve(DidCommMessageRepository) + + const request = await didCommMessageRepository.findAgentMessage(proverAgent.context, { + associatedRecordId: proverProofExchangeRecord.id, + messageClass: V2RequestPresentationMessage, + }) + + expect(request).toMatchObject({ + type: 'https://didcomm.org/present-proof/2.0/request-presentation', + id: expect.any(String), + formats: [ + { + attachmentId: expect.any(String), + format: 'dif/presentation-exchange/definitions@v1.0', + }, + ], + requestAttachments: [ + { + id: expect.any(String), + mimeType: 'application/json', + data: { + json: { + presentation_definition: { + id: expect.any(String), + input_descriptors: [ + { + id: TEST_INPUT_DESCRIPTORS_CITIZENSHIP.id, + constraints: { + fields: TEST_INPUT_DESCRIPTORS_CITIZENSHIP.constraints.fields, + }, + }, + ], + }, + }, + }, + }, + ], + }) + + expect(proverProofExchangeRecord).toMatchObject({ + id: expect.any(String), + threadId: verifierProofExchangeRecord.threadId, + state: ProofState.RequestReceived, + protocolVersion: 'v2', + }) + }) + + test(`Prover accepts presentation request from the Verifier`, async () => { + // Prover retrieves the requested credentials and accepts the presentation request + testLogger.test('Prover accepts presentation request from Verifier') + + const verifierPresentationRecordPromise = waitForProofExchangeRecord(verifierAgent, { + threadId: verifierProofExchangeRecord.threadId, + state: ProofState.PresentationReceived, + }) + + await proverAgent.proofs.acceptRequest({ + proofRecordId: proverProofExchangeRecord.id, + }) + + // Verifier waits for the presentation from the Prover + testLogger.test('Verifier waits for presentation from the Prover') + verifierProofExchangeRecord = await verifierPresentationRecordPromise + + const presentation = await didCommMessageRepository.findAgentMessage(verifierAgent.context, { + associatedRecordId: verifierProofExchangeRecord.id, + messageClass: V2PresentationMessage, + }) + + // { + // "@type":"https://didcomm.org/present-proof/2.0/presentation", + // "last_presentation":true, + // "formats":[ + // { + // "attach_id":"97cf1dbf-2ce0-4641-9083-00f4aec99478", + // "format":"dif/presentation-exchange/submission@v1.0" + // } + // ], + // "presentations~attach":[ + // { + // "@id":"97cf1dbf-2ce0-4641-9083-00f4aec99478", + // "mime-type":"application/json", + // "data":{ + // "json":{ + // "presentation_submission":{ + // "id":"dHOs_n7UF7QAbJvEovHeW", + // "definition_id":"e950bfe5-d7ec-4303-ad61-6983fb976ac9", + // "descriptor_map":[ + // { + // "id":"citizenship_input_1", + // "format":"ldp_vp", + // "path":"$", + // "path_nested":{ + // "id":"citizenship_input_1", + // "format":"ldp_vc ", + // "path":"$.verifiableCredential[0]" + // } + // } + // ] + // }, + // "context":[ + // "https://www.w3.org/2018/credentials/v1" + // ], + // "type":[ + // "VerifiableP resentation" + // ], + // "holder":"did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + // "verifiableCredential":[ + // { + // "@context":[ + // "https://www.w3.org/2018/credentials/v1", + // "https://www.w3.org/2018/credentials/examples/v1" + // ], + // "type":[ + // "Verifiab leCredential", + // "UniversityDegreeCredential" + // ], + // "issuer":"did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + // "issuanceDate":"2017-10-22T12:23:48Z", + // "credentialSubject":{ + // "id":"did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38Eef XmgDL", + // "degree":{ + // "type":"BachelorDegree", + // "name":"Bachelor of Science and Arts" + // } + // }, + // "proof":{ + // "verificationMethod":"di d:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + // "type":"E d25519Signature2018", + // "created":"2023-12-19T12:38:36Z", + // "proofPurpose":"assertionMethod", + // "jws":"eyJhbGciOiJFZERTQSIs ImI2NCI6ZmFsc2UsImNyaXQiOlsiYjY0Il19..U3oPjRgz-fTd_kkUtNgWKh-FRWWkKdy0iSgOiGA1d7IyImuL1URQwJjd3UlJAkFf1kl7NeakiCtZ cFfxkPpECQ" + // } + // } + // ], + // "proof":{ + // "verificationMethod":"did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Yc puk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL", + // "type":"Ed25519Signature2018", + // "created":"2023-12-19T12:38:37Z", + // "proofPurpos e":"authentication", + // "challenge":"273899451763000636595367", + // "jws":"eyJhbGciOiJFZERTQSIsImI2NCI6ZmFsc2UsImNyaXQiOlsi YjY0Il19..X_pR5Evhj-byuMkhJfXfoj9HO03iLKtltq64A4cueuLAH-Ix5D-G9g7r4xec9ysyga8GS2tZQl0OK4W9LJcOAQ" + // } + // } + // } + // } + // ], + // "@id":"2cdf aa16-d132-4778-9d6f-622fc0e0fa84", + // "~thread":{ + // "thid":"e03cfab3-7ab1-477f-9df7-dc7ede70b952" + // }, + // "~please_ack":{ + // "on":[ + // " RECEIPT" + // ] + // } + // } + + expect(presentation).toMatchObject({ + type: 'https://didcomm.org/present-proof/2.0/presentation', + formats: [ + { + attachmentId: expect.any(String), + format: 'dif/presentation-exchange/submission@v1.0', + }, + ], + presentationAttachments: [ + { + id: expect.any(String), + mimeType: 'application/json', + data: { + json: { + '@context': expect.any(Array), + type: expect.any(Array), + presentation_submission: { + id: expect.any(String), + definition_id: expect.any(String), + descriptor_map: [ + { + id: 'citizenship_input_1', + format: 'ldp_vc', + path: '$.verifiableCredential[0]', + }, + ], + }, + verifiableCredential: [ + { + '@context': [ + 'https://www.w3.org/2018/credentials/v1', + 'https://www.w3.org/2018/credentials/examples/v1', + ], + type: ['VerifiableCredential', 'UniversityDegreeCredential'], + issuer: expect.any(String), + issuanceDate: expect.any(String), + credentialSubject: { + id: expect.any(String), + degree: { + type: 'BachelorDegree', + name: 'Bachelor of Science and Arts', + }, + }, + proof: { + verificationMethod: expect.any(String), + type: 'Ed25519Signature2018', + created: expect.any(String), + proofPurpose: 'assertionMethod', + jws: expect.any(String), + }, + }, + ], + proof: { + verificationMethod: expect.any(String), + type: 'Ed25519Signature2018', + created: expect.any(String), + proofPurpose: 'authentication', + challenge: expect.any(String), + jws: expect.any(String), + }, + }, + }, + }, + ], + id: expect.any(String), + thread: { + threadId: verifierProofExchangeRecord.threadId, + }, + }) + + expect(verifierProofExchangeRecord.id).not.toBeNull() + expect(verifierProofExchangeRecord).toMatchObject({ + threadId: verifierProofExchangeRecord.threadId, + state: ProofState.PresentationReceived, + protocolVersion: 'v2', + }) + }) + + test(`Verifier accepts the presentation provided by the Prover`, async () => { + const proverProofExchangeRecordPromise = waitForProofExchangeRecord(proverAgent, { + threadId: proverProofExchangeRecord.threadId, + state: ProofState.Done, + }) + + // Verifier accepts the presentation provided by by the Prover + testLogger.test('Verifier accepts the presentation provided by the Prover') + await verifierAgent.proofs.acceptPresentation({ proofRecordId: verifierProofExchangeRecord.id }) + + // Prover waits until she received a presentation acknowledgement + testLogger.test('Prover waits until she receives a presentation acknowledgement') + proverProofExchangeRecord = await proverProofExchangeRecordPromise + + expect(verifierProofExchangeRecord).toMatchObject({ + id: expect.any(String), + createdAt: expect.any(Date), + threadId: proverProofExchangeRecord.threadId, + connectionId: expect.any(String), + isVerified: true, + state: ProofState.PresentationReceived, + }) + + expect(proverProofExchangeRecord).toMatchObject({ + id: expect.any(String), + createdAt: expect.any(Date), + threadId: verifierProofExchangeRecord.threadId, + connectionId: expect.any(String), + state: ProofState.Done, + }) + }) +}) diff --git a/packages/core/src/modules/vc/models/presentation/W3cJsonPresentation.ts b/packages/core/src/modules/vc/models/presentation/W3cJsonPresentation.ts index 5625627ef8..a47b3e90dc 100644 --- a/packages/core/src/modules/vc/models/presentation/W3cJsonPresentation.ts +++ b/packages/core/src/modules/vc/models/presentation/W3cJsonPresentation.ts @@ -1,4 +1,5 @@ import type { JsonObject } from '../../../../types' +import type { DifPresentationExchangeSubmission } from '../../../dif-presentation-exchange' import type { W3cJsonCredential } from '../credential/W3cJsonCredential' export interface W3cJsonPresentation { @@ -7,5 +8,6 @@ export interface W3cJsonPresentation { type: Array holder: string | { id?: string } verifiableCredential: Array + presentation_submission?: DifPresentationExchangeSubmission [key: string]: unknown } diff --git a/packages/core/src/modules/vc/models/presentation/W3cPresentation.ts b/packages/core/src/modules/vc/models/presentation/W3cPresentation.ts index 299d0fbbc4..cf37fc434b 100644 --- a/packages/core/src/modules/vc/models/presentation/W3cPresentation.ts +++ b/packages/core/src/modules/vc/models/presentation/W3cPresentation.ts @@ -1,4 +1,5 @@ import type { W3cHolderOptions } from './W3cHolder' +import type { W3cJsonPresentation } from './W3cJsonPresentation' import type { JsonObject } from '../../../../types' import type { W3cVerifiableCredential } from '../credential/W3cVerifiableCredential' import type { ValidationOptions } from 'class-validator' @@ -6,6 +7,7 @@ import type { ValidationOptions } from 'class-validator' import { Expose } from 'class-transformer' import { ValidateNested, buildMessage, IsOptional, ValidateBy } from 'class-validator' +import { JsonTransformer } from '../../../../utils' import { SingleOrArray } from '../../../../utils/type' import { IsUri, IsInstanceOrArrayOfInstances } from '../../../../utils/validators' import { CREDENTIALS_CONTEXT_V1_URL, VERIFIABLE_PRESENTATION_TYPE } from '../../constants' @@ -64,6 +66,10 @@ export class W3cPresentation { return this.holder instanceof W3cHolder ? this.holder.id : this.holder } + + public toJSON() { + return JsonTransformer.toJSON(this) as W3cJsonPresentation + } } // Custom validators diff --git a/packages/core/src/utils/objectEquality.ts b/packages/core/src/utils/objectEquality.ts index 5288d1a52d..33db64084a 100644 --- a/packages/core/src/utils/objectEquality.ts +++ b/packages/core/src/utils/objectEquality.ts @@ -1,14 +1,16 @@ // eslint-disable-next-line @typescript-eslint/no-explicit-any -export function areObjectsEqual(a: any, b: any): boolean { +export function areObjectsEqual(a: A, b: B): boolean { if (typeof a == 'object' && a != null && typeof b == 'object' && b != null) { - if (Object.keys(a).length !== Object.keys(b).length) return false - for (const key in a) { - if (!(key in b) || !areObjectsEqual(a[key], b[key])) { + const definedA = Object.fromEntries(Object.entries(a).filter(([, value]) => value !== undefined)) + const definedB = Object.fromEntries(Object.entries(b).filter(([, value]) => value !== undefined)) + if (Object.keys(definedA).length !== Object.keys(definedB).length) return false + for (const key in definedA) { + if (!(key in definedB) || !areObjectsEqual(definedA[key], definedB[key])) { return false } } - for (const key in b) { - if (!(key in a) || !areObjectsEqual(b[key], a[key])) { + for (const key in definedB) { + if (!(key in definedA) || !areObjectsEqual(definedB[key], definedA[key])) { return false } } diff --git a/packages/core/tests/jsonld.ts b/packages/core/tests/jsonld.ts index 9b0f211097..1cfb263ab0 100644 --- a/packages/core/tests/jsonld.ts +++ b/packages/core/tests/jsonld.ts @@ -5,6 +5,8 @@ import { BbsModule } from '../../bbs-signatures/src/BbsModule' import { IndySdkModule } from '../../indy-sdk/src' import { indySdk } from '../../indy-sdk/tests/setupIndySdkModule' import { + PresentationExchangeProofFormatService, + V2ProofProtocol, CacheModule, CredentialEventTypes, InMemoryLruCache, @@ -38,6 +40,7 @@ export const getJsonLdModules = ({ }), proofs: new ProofsModule({ autoAcceptProofs, + proofProtocols: [new V2ProofProtocol({ proofFormats: [new PresentationExchangeProofFormatService()] })], }), cache: new CacheModule({ cache: new InMemoryLruCache({ limit: 100 }),