diff --git a/demo/src/BaseAgent.ts b/demo/src/BaseAgent.ts index c7b9902686..170ead2012 100644 --- a/demo/src/BaseAgent.ts +++ b/demo/src/BaseAgent.ts @@ -23,12 +23,14 @@ import { DidsModule, V2ProofProtocol, V2CredentialProtocol, + V3CredentialProtocol, ProofsModule, AutoAcceptProof, AutoAcceptCredential, CredentialsModule, Agent, HttpOutboundTransport, + V3ProofProtocol, } from '@aries-framework/core' import { IndySdkAnonCredsRegistry, IndySdkModule, IndySdkSovDidResolver } from '@aries-framework/indy-sdk' import { IndyVdrIndyDidResolver, IndyVdrAnonCredsRegistry, IndyVdrModule } from '@aries-framework/indy-vdr' @@ -122,6 +124,9 @@ function getAskarAnonCredsIndyModules() { new V2CredentialProtocol({ credentialFormats: [legacyIndyCredentialFormatService, new AnonCredsCredentialFormatService()], }), + new V3CredentialProtocol({ + credentialFormats: [legacyIndyCredentialFormatService, new AnonCredsCredentialFormatService()], + }), ], }), proofs: new ProofsModule({ @@ -133,6 +138,9 @@ function getAskarAnonCredsIndyModules() { new V2ProofProtocol({ proofFormats: [legacyIndyProofFormatService, new AnonCredsProofFormatService()], }), + new V3ProofProtocol({ + proofFormats: [legacyIndyProofFormatService, new AnonCredsProofFormatService()], + }), ], }), anoncreds: new AnonCredsModule({ diff --git a/demo/src/Faber.ts b/demo/src/Faber.ts index 1b66998405..813f553602 100644 --- a/demo/src/Faber.ts +++ b/demo/src/Faber.ts @@ -203,7 +203,7 @@ export class Faber extends BaseAgent { await this.agent.credentials.offerCredential({ connectionId: connectionRecord.id, - protocolVersion: 'v2', + protocolVersion: connectionRecord.isDidCommV1Connection ? 'v2' : 'v3', credentialFormats: { anoncreds: { attributes: [ @@ -256,7 +256,7 @@ export class Faber extends BaseAgent { await this.printProofFlow(greenText('\nRequesting proof...\n', false)) await this.agent.proofs.requestProof({ - protocolVersion: 'v2', + protocolVersion: connectionRecord.isDidCommV1Connection ? 'v2' : 'v3', connectionId: connectionRecord.id, proofFormats: { anoncreds: { diff --git a/demo/src/Listener.ts b/demo/src/Listener.ts index 4a4c7e383a..3b98345569 100644 --- a/demo/src/Listener.ts +++ b/demo/src/Listener.ts @@ -4,7 +4,6 @@ import type { Faber } from './Faber' import type { FaberInquirer } from './FaberInquirer' import type { Agent, - BasicMessageStateChangedEvent, CredentialExchangeRecord, CredentialStateChangedEvent, TrustPingReceivedEvent, @@ -13,12 +12,15 @@ import type { V2TrustPingResponseReceivedEvent, ProofExchangeRecord, ProofStateChangedEvent, + AgentMessageProcessedEvent, + AgentBaseMessage, } from '@aries-framework/core' import type BottomBar from 'inquirer/lib/ui/bottom-bar' import { - BasicMessageEventTypes, - BasicMessageRole, + V1BasicMessage, + V2BasicMessage, + AgentEventTypes, CredentialEventTypes, CredentialState, ProofEventTypes, @@ -76,9 +78,14 @@ export class Listener { } public messageListener(agent: Agent, name: string) { - agent.events.on(BasicMessageEventTypes.BasicMessageStateChanged, async (event: BasicMessageStateChangedEvent) => { - if (event.payload.basicMessageRecord.role === BasicMessageRole.Receiver) { - this.ui.updateBottomBar(purpleText(`\n${name} received a message: ${event.payload.message.content}\n`)) + const isBasicMessage = (message: AgentBaseMessage): message is V1BasicMessage | V2BasicMessage => + [V1BasicMessage.type.messageTypeUri, V2BasicMessage.type.messageTypeUri].includes(message.type) + + agent.events.on(AgentEventTypes.AgentMessageProcessed, async (event: AgentMessageProcessedEvent) => { + const message = event.payload.message + + if (isBasicMessage(message)) { + this.ui.updateBottomBar(purpleText(`\n${name} received a message: ${message.content}\n`)) } }) } diff --git a/packages/core/src/agent/BaseAgent.ts b/packages/core/src/agent/BaseAgent.ts index 39ea8d521d..bf57ad5fd6 100644 --- a/packages/core/src/agent/BaseAgent.ts +++ b/packages/core/src/agent/BaseAgent.ts @@ -2,6 +2,7 @@ import type { AgentConfig } from './AgentConfig' import type { AgentApi, CustomOrDefaultApi, EmptyModuleMap, ModulesMap, WithoutDefaultModules } from './AgentModules' import type { TransportSession } from './TransportService' import type { Logger } from '../logger' +import type { BasicMessagesModule } from '../modules/basic-messages' import type { CredentialsModule } from '../modules/credentials' import type { MessagePickupModule } from '../modules/message-pìckup' import type { ProofsModule } from '../modules/proofs' @@ -51,7 +52,7 @@ export abstract class BaseAgent - public readonly basicMessages: BasicMessagesApi + public readonly basicMessages: CustomOrDefaultApi public readonly genericRecords: GenericRecordsApi public readonly discovery: DiscoverFeaturesApi public readonly dids: DidsApi @@ -99,7 +100,10 @@ export abstract class BaseAgent - this.basicMessages = this.dependencyManager.resolve(BasicMessagesApi) + this.basicMessages = this.dependencyManager.resolve(BasicMessagesApi) as CustomOrDefaultApi< + AgentModules['basicMessages'], + BasicMessagesModule + > this.genericRecords = this.dependencyManager.resolve(GenericRecordsApi) this.discovery = this.dependencyManager.resolve(DiscoverFeaturesApi) this.dids = this.dependencyManager.resolve(DidsApi) diff --git a/packages/core/src/agent/__tests__/Agent.test.ts b/packages/core/src/agent/__tests__/Agent.test.ts index ed3154f8fd..60c1344b31 100644 --- a/packages/core/src/agent/__tests__/Agent.test.ts +++ b/packages/core/src/agent/__tests__/Agent.test.ts @@ -5,7 +5,7 @@ import { injectable } from 'tsyringe' import { getIndySdkModules } from '../../../../indy-sdk/tests/setupIndySdkModule' import { getAgentOptions } from '../../../tests/helpers' import { InjectionSymbols } from '../../constants' -import { BasicMessageRepository, BasicMessageService } from '../../modules/basic-messages' +import { BasicMessageRepository } from '../../modules/basic-messages' import { BasicMessagesApi } from '../../modules/basic-messages/BasicMessagesApi' import { ConnectionsApi } from '../../modules/connections/ConnectionsApi' import { V1TrustPingService } from '../../modules/connections/protocols/trust-ping/v1/V1TrustPingService' @@ -167,7 +167,6 @@ describe('Agent', () => { expect(container.resolve(CredentialRepository)).toBeInstanceOf(CredentialRepository) expect(container.resolve(BasicMessagesApi)).toBeInstanceOf(BasicMessagesApi) - expect(container.resolve(BasicMessageService)).toBeInstanceOf(BasicMessageService) expect(container.resolve(BasicMessageRepository)).toBeInstanceOf(BasicMessageRepository) expect(container.resolve(MediatorApi)).toBeInstanceOf(MediatorApi) @@ -205,7 +204,6 @@ describe('Agent', () => { expect(container.resolve(CredentialRepository)).toBe(container.resolve(CredentialRepository)) expect(container.resolve(BasicMessagesApi)).toBe(container.resolve(BasicMessagesApi)) - expect(container.resolve(BasicMessageService)).toBe(container.resolve(BasicMessageService)) expect(container.resolve(BasicMessageRepository)).toBe(container.resolve(BasicMessageRepository)) expect(container.resolve(MediatorApi)).toBe(container.resolve(MediatorApi)) @@ -242,10 +240,13 @@ describe('Agent', () => { expect(protocols).toEqual( expect.arrayContaining([ 'https://didcomm.org/basicmessage/1.0', + 'https://didcomm.org/basicmessage/2.0', 'https://didcomm.org/connections/1.0', 'https://didcomm.org/coordinate-mediation/1.0', 'https://didcomm.org/issue-credential/2.0', + 'https://didcomm.org/issue-credential/3.0', 'https://didcomm.org/present-proof/2.0', + 'https://didcomm.org/present-proof/3.0', 'https://didcomm.org/didexchange/1.0', 'https://didcomm.org/discover-features/1.0', 'https://didcomm.org/discover-features/2.0', @@ -256,6 +257,6 @@ describe('Agent', () => { 'https://didcomm.org/revocation_notification/2.0', ]) ) - expect(protocols.length).toEqual(13) + expect(protocols.length).toEqual(16) }) }) diff --git a/packages/core/src/agent/getOutboundMessageContext.ts b/packages/core/src/agent/getOutboundMessageContext.ts index ea57257da8..d6f4101b44 100644 --- a/packages/core/src/agent/getOutboundMessageContext.ts +++ b/packages/core/src/agent/getOutboundMessageContext.ts @@ -1,5 +1,5 @@ +import type { AgentBaseMessage } from './AgentBaseMessage' import type { AgentContext } from './context' -import type { DidCommV1Message } from '../didcomm/versions/v1' import type { ConnectionRecord, Routing } from '../modules/connections' import type { ResolvedDidCommService } from '../modules/didcomm' import type { OutOfBandRecord } from '../modules/oob' @@ -7,6 +7,7 @@ import type { BaseRecordAny } from '../storage/BaseRecord' import { Key } from '../crypto' import { ServiceDecorator } from '../decorators/service/ServiceDecorator' +import { DidCommV1Message, DidCommV2Message } from '../didcomm' import { AriesFrameworkError } from '../error' import { OutOfBandService, OutOfBandRole, OutOfBandRepository } from '../modules/oob' import { OutOfBandRecordMetadataKeys } from '../modules/oob/repository/outOfBandRecordMetadataTypes' @@ -37,9 +38,9 @@ export async function getOutboundMessageContext( }: { connectionRecord?: ConnectionRecord associatedRecord?: BaseRecordAny - message: DidCommV1Message - lastReceivedMessage?: DidCommV1Message - lastSentMessage?: DidCommV1Message + message: AgentBaseMessage + lastReceivedMessage?: AgentBaseMessage + lastSentMessage?: AgentBaseMessage } ) { // TODO: even if using a connection record, we should check if there's an oob record associated and this @@ -48,6 +49,17 @@ export async function getOutboundMessageContext( agentContext.config.logger.debug( `Creating outbound message context for message ${message.id} with connection ${connectionRecord.id}` ) + + // Attach 'from' and 'to' fields according to connection record (unless they are previously defined) + if (message instanceof DidCommV2Message) { + message.from = message.from ?? connectionRecord.did + const recipients = message.to ?? (connectionRecord.theirDid ? [connectionRecord.theirDid] : undefined) + if (!recipients) { + throw new AriesFrameworkError('Cannot find recipient did for message') + } + message.to = recipients + } + return new OutboundMessageContext(message, { agentContext, associatedRecord, @@ -67,6 +79,14 @@ export async function getOutboundMessageContext( ) } + if ( + !(message instanceof DidCommV1Message) || + (lastReceivedMessage !== undefined && !(lastReceivedMessage instanceof DidCommV1Message)) || + (lastSentMessage !== undefined && !(lastSentMessage instanceof DidCommV1Message)) + ) { + throw new AriesFrameworkError('No connection record associated with DIDComm V2 messages exchange') + } + // Connectionless return getConnectionlessOutboundMessageContext(agentContext, { message, diff --git a/packages/core/src/decorators/attachment/v2/V2Attachment.ts b/packages/core/src/decorators/attachment/v2/V2Attachment.ts index e46aad845c..6b4e4aa89f 100644 --- a/packages/core/src/decorators/attachment/v2/V2Attachment.ts +++ b/packages/core/src/decorators/attachment/v2/V2Attachment.ts @@ -1,7 +1,8 @@ +import type { JwsDetachedFormat, JwsFlattenedDetachedFormat } from '../../../crypto/JwsTypes' + import { Expose, Type } from 'class-transformer' -import { IsBase64, IsInstance, IsMimeType, IsOptional, IsString, ValidateNested } from 'class-validator' +import { IsBase64, IsDate, IsInstance, IsInt, IsMimeType, IsOptional, IsString, ValidateNested } from 'class-validator' -import { Jws } from '../../../crypto/JwsTypes' import { AriesFrameworkError } from '../../../error' import { JsonEncoder } from '../../../utils/JsonEncoder' import { uuid } from '../../../utils/uuid' @@ -12,6 +13,8 @@ export interface V2AttachmentOptions { id?: string description?: string filename?: string + format?: string + lastmodTime?: Date mediaType?: string byteCount?: number data: V2AttachmentData @@ -21,7 +24,8 @@ export interface V2AttachmentDataOptions { base64?: string json?: Record links?: string[] - jws?: Jws + jws?: JwsDetachedFormat | JwsFlattenedDetachedFormat + hash?: string } /** @@ -52,7 +56,14 @@ export class V2AttachmentData { * A JSON Web Signature over the content of the attachment. Optional. */ @IsOptional() - public jws?: Jws + public jws?: JwsDetachedFormat | JwsFlattenedDetachedFormat + + /** + * The hash of the content encoded in multi-hash format. Used as an integrity check for the attachment, and MUST be used if the data is referenced via the links data attribute. + */ + @IsOptional() + @IsString() + public hash?: string public constructor(options: V2AttachmentDataOptions) { if (options) { @@ -60,6 +71,7 @@ export class V2AttachmentData { this.json = options.json this.links = options.links this.jws = options.jws + this.hash = options.hash } } } @@ -73,7 +85,10 @@ export class V2Attachment { if (options) { this.id = options.id ?? uuid() this.description = options.description + this.byteCount = options.byteCount this.filename = options.filename + this.format = options.format + this.lastmodTime = options.lastmodTime this.mediaType = options.mediaType this.data = options.data } @@ -110,6 +125,23 @@ export class V2Attachment { @IsMimeType() public mediaType?: string + /** + * A hint about when the content in this attachment was last modified. + */ + @Expose({ name: 'lastmod_time' }) + @Type(() => Date) + @IsOptional() + @IsDate() + public lastmodTime?: Date + + /** + * Optional, and mostly relevant when content is included by reference instead of by value. Lets the receiver guess how expensive it will be, in time, bandwidth, and storage, to fully fetch the attachment. + */ + @Expose({ name: 'byte_count' }) + @IsOptional() + @IsInt() + public byteCount?: number + @Type(() => V2AttachmentData) @ValidateNested() @IsInstance(V2AttachmentData) diff --git a/packages/core/src/decorators/thread/ThreadDecoratorExtension.ts b/packages/core/src/decorators/thread/ThreadDecoratorExtension.ts index 5141e5a051..a7cad5f7a0 100644 --- a/packages/core/src/decorators/thread/ThreadDecoratorExtension.ts +++ b/packages/core/src/decorators/thread/ThreadDecoratorExtension.ts @@ -21,6 +21,10 @@ export function ThreadDecorated(Base: return this.thread?.threadId ?? this.id } + public get parentThreadId(): string | undefined { + return this.thread?.parentThreadId + } + public setThread(options: Partial) { this.thread = new ThreadDecorator(options) } diff --git a/packages/core/src/didcomm/index.ts b/packages/core/src/didcomm/index.ts index e74379142a..3b11531e8b 100644 --- a/packages/core/src/didcomm/index.ts +++ b/packages/core/src/didcomm/index.ts @@ -6,6 +6,7 @@ import type { Constructor } from '../utils/mixins' export * from './versions/v1' export * from './versions/v2' export * from './types' +export * from './transformers' export * from './helpers' export * from './JweEnvelope' diff --git a/packages/core/src/didcomm/transformers.ts b/packages/core/src/didcomm/transformers.ts new file mode 100644 index 0000000000..7e29a728af --- /dev/null +++ b/packages/core/src/didcomm/transformers.ts @@ -0,0 +1,39 @@ +import { Attachment, AttachmentData, V2Attachment, V2AttachmentData } from '../decorators/attachment' + +export function toV2Attachment(v1Attachment: Attachment): V2Attachment { + const { id, description, byteCount, filename, lastmodTime, mimeType, data } = v1Attachment + return new V2Attachment({ + id, + description, + byteCount, + filename, + lastmodTime, + mediaType: mimeType, + data: new V2AttachmentData({ + base64: data.base64, + json: data.json, + jws: data.jws, + links: data.links, + hash: data.sha256, + }), + }) +} + +export function toV1Attachment(v2Attachment: V2Attachment): Attachment { + const { id, description, byteCount, filename, lastmodTime, mediaType, data } = v2Attachment + return new Attachment({ + id, + description, + byteCount, + filename, + lastmodTime, + mimeType: mediaType, + data: new AttachmentData({ + base64: data.base64, + json: data.json, + jws: data.jws, + links: data.links, + sha256: data.hash, + }), + }) +} diff --git a/packages/core/src/didcomm/versions/v2/DidCommV2BaseMessage.ts b/packages/core/src/didcomm/versions/v2/DidCommV2BaseMessage.ts index 4e17749e91..dd3927ca93 100644 --- a/packages/core/src/didcomm/versions/v2/DidCommV2BaseMessage.ts +++ b/packages/core/src/didcomm/versions/v2/DidCommV2BaseMessage.ts @@ -17,13 +17,22 @@ export type DidCommV2MessageParams = { to?: string | string[] thid?: string parentThreadId?: string + senderOrder?: number + receivedOrders?: { [key: string]: number } // TODO: Update to DIDComm V2 format createdTime?: number expiresTime?: number fromPrior?: string + language?: string attachments?: Array body?: unknown } +type DidCommV2ReceiverOrder = { + id: string + last: number + gaps: number[] +} + export class DidCommV2BaseMessage { @Matches(MessageIdRegExp) public id!: string @@ -63,11 +72,26 @@ export class DidCommV2BaseMessage { @IsOptional() public parentThreadId?: string + @Expose({ name: 'sender_order' }) + @IsNumber() + @IsOptional() + public senderOrder?: number + + @Expose({ name: 'received_orders' }) + @IsOptional() + @IsArray() + public receivedOrders?: DidCommV2ReceiverOrder[] + @Expose({ name: 'from_prior' }) @IsString() @IsOptional() public fromPrior?: string + @Expose({ name: 'lang' }) + @IsString() + @IsOptional() + public language?: string + public body!: unknown @IsOptional() @@ -86,6 +110,12 @@ export class DidCommV2BaseMessage { this.to = typeof options.to === 'string' ? [options.to] : options.to this.thid = options.thid this.parentThreadId = options.parentThreadId + this.senderOrder = options.senderOrder + this.receivedOrders = Object.entries(options.receivedOrders ?? {}).map(([id, last]) => ({ + id, + last, + gaps: [], + })) this.createdTime = options.createdTime this.expiresTime = options.expiresTime this.fromPrior = options.fromPrior diff --git a/packages/core/src/didcomm/versions/v2/DidCommV2Message.ts b/packages/core/src/didcomm/versions/v2/DidCommV2Message.ts index a45f45da6f..d4a3596180 100644 --- a/packages/core/src/didcomm/versions/v2/DidCommV2Message.ts +++ b/packages/core/src/didcomm/versions/v2/DidCommV2Message.ts @@ -1,6 +1,7 @@ import type { PlaintextDidCommV2Message } from './types' import type { AgentBaseMessage } from '../../../agent/AgentBaseMessage' import type { ServiceDecorator } from '../../../decorators/service/ServiceDecorator' +import type { ThreadDecorator } from '../../../decorators/thread/ThreadDecorator' import type { PlaintextMessage } from '../../types' import { AriesFrameworkError } from '../../../error' @@ -22,8 +23,19 @@ export class DidCommV2Message extends DidCommV2BaseMessage implements AgentBaseM return undefined } - public get threadId(): string | undefined { - return this.thid + public get threadId(): string { + return this.thid ?? this.id + } + + public setThread(options: Partial) { + this.thid = options.threadId + this.parentThreadId = options.parentThreadId + this.senderOrder = options.senderOrder + this.receivedOrders = Object.entries(options.receivedOrders ?? {}).map(([id, last]) => ({ + id, + last, + gaps: [], + })) } public hasAnyReturnRoute() { diff --git a/packages/core/src/modules/basic-messages/BasicMessageEvents.ts b/packages/core/src/modules/basic-messages/BasicMessageEvents.ts index f05873f5de..c02bf4832e 100644 --- a/packages/core/src/modules/basic-messages/BasicMessageEvents.ts +++ b/packages/core/src/modules/basic-messages/BasicMessageEvents.ts @@ -1,4 +1,4 @@ -import type { BasicMessage } from './messages' +import type { V1BasicMessage } from './protocols' import type { BasicMessageRecord } from './repository' import type { BaseEvent } from '../../agent/Events' @@ -8,7 +8,7 @@ export enum BasicMessageEventTypes { export interface BasicMessageStateChangedEvent extends BaseEvent { type: typeof BasicMessageEventTypes.BasicMessageStateChanged payload: { - message: BasicMessage + message: V1BasicMessage basicMessageRecord: BasicMessageRecord } } diff --git a/packages/core/src/modules/basic-messages/BasicMessagesApi.ts b/packages/core/src/modules/basic-messages/BasicMessagesApi.ts index 82e94cf9e4..df1328b1f3 100644 --- a/packages/core/src/modules/basic-messages/BasicMessagesApi.ts +++ b/packages/core/src/modules/basic-messages/BasicMessagesApi.ts @@ -1,35 +1,59 @@ +import type { BasicMessageProtocol } from './protocols' import type { BasicMessageRecord } from './repository/BasicMessageRecord' import type { Query } from '../../storage/StorageService' import { AgentContext } from '../../agent' -import { MessageHandlerRegistry } from '../../agent/MessageHandlerRegistry' import { MessageSender } from '../../agent/MessageSender' -import { OutboundMessageContext } from '../../agent/models' +import { getOutboundMessageContext } from '../../agent/getOutboundMessageContext' +import { AriesFrameworkError } from '../../error' import { injectable } from '../../plugins' import { ConnectionService } from '../connections' -import { BasicMessageHandler } from './handlers' -import { BasicMessageService } from './services' +import { BasicMessagesModuleConfig } from './BasicMessagesModuleConfig' +import { BasicMessageRepository } from './repository' + +export interface BasicMessagesApi { + sendMessage(connectionId: string, message: string, parentThreadId?: string): Promise + + findAllByQuery(query: Query): Promise + getById(basicMessageRecordId: string): Promise + getByThreadId(threadId: string): Promise + deleteById(basicMessageRecordId: string): Promise +} @injectable() -export class BasicMessagesApi { - private basicMessageService: BasicMessageService +export class BasicMessagesApi implements BasicMessagesApi { + public readonly config: BasicMessagesModuleConfig + + private basicMessageRepository: BasicMessageRepository private messageSender: MessageSender private connectionService: ConnectionService private agentContext: AgentContext public constructor( - messageHandlerRegistry: MessageHandlerRegistry, - basicMessageService: BasicMessageService, + basicMessageRepository: BasicMessageRepository, messageSender: MessageSender, connectionService: ConnectionService, - agentContext: AgentContext + agentContext: AgentContext, + config: BasicMessagesModuleConfig ) { - this.basicMessageService = basicMessageService + this.basicMessageRepository = basicMessageRepository this.messageSender = messageSender this.connectionService = connectionService this.agentContext = agentContext - this.registerMessageHandlers(messageHandlerRegistry) + this.config = config + } + + private getProtocol(protocolVersion: PVT): BasicMessageProtocol { + const basicMessageProtocol = this.config.basicMessageProtocols.find( + (protocol) => protocol.version === protocolVersion + ) + + if (!basicMessageProtocol) { + throw new AriesFrameworkError(`No basic message protocol registered for protocol version ${protocolVersion}`) + } + + return basicMessageProtocol } /** @@ -44,15 +68,20 @@ export class BasicMessagesApi { public async sendMessage(connectionId: string, message: string, parentThreadId?: string) { const connection = await this.connectionService.getById(this.agentContext, connectionId) - const { message: basicMessage, record: basicMessageRecord } = await this.basicMessageService.createMessage( + // TODO: Parameterize in API + const basicMessageProtocol = this.getProtocol(connection.isDidCommV1Connection ? 'v1' : 'v2') + + const { message: basicMessage, record: basicMessageRecord } = await basicMessageProtocol.createMessage( this.agentContext, - message, - connection, - parentThreadId + { + content: message, + connectionRecord: connection, + parentThreadId, + } ) - const outboundMessageContext = new OutboundMessageContext(basicMessage, { - agentContext: this.agentContext, - connection, + const outboundMessageContext = await getOutboundMessageContext(this.agentContext, { + message: basicMessage, + connectionRecord: connection, associatedRecord: basicMessageRecord, }) @@ -67,7 +96,7 @@ export class BasicMessagesApi { * @returns array containing all matching records */ public async findAllByQuery(query: Query) { - return this.basicMessageService.findAllByQuery(this.agentContext, query) + return this.basicMessageRepository.findByQuery(this.agentContext, query) } /** @@ -79,7 +108,7 @@ export class BasicMessagesApi { * */ public async getById(basicMessageRecordId: string) { - return this.basicMessageService.getById(this.agentContext, basicMessageRecordId) + return this.basicMessageRepository.getById(this.agentContext, basicMessageRecordId) } /** @@ -90,8 +119,8 @@ export class BasicMessagesApi { * @throws {RecordDuplicateError} If multiple records are found * @returns The connection record */ - public async getByThreadId(basicMessageRecordId: string) { - return this.basicMessageService.getByThreadId(this.agentContext, basicMessageRecordId) + public async getByThreadId(threadId: string) { + return this.basicMessageRepository.getSingleByQuery(this.agentContext, { threadId }) } /** @@ -101,10 +130,6 @@ export class BasicMessagesApi { * @throws {RecordNotFoundError} If no record is found */ public async deleteById(basicMessageRecordId: string) { - await this.basicMessageService.deleteById(this.agentContext, basicMessageRecordId) - } - - private registerMessageHandlers(messageHandlerRegistry: MessageHandlerRegistry) { - messageHandlerRegistry.registerMessageHandler(new BasicMessageHandler(this.basicMessageService)) + await this.basicMessageRepository.deleteById(this.agentContext, basicMessageRecordId) } } diff --git a/packages/core/src/modules/basic-messages/BasicMessagesModule.ts b/packages/core/src/modules/basic-messages/BasicMessagesModule.ts index fd1fd77f6c..95a9bb1210 100644 --- a/packages/core/src/modules/basic-messages/BasicMessagesModule.ts +++ b/packages/core/src/modules/basic-messages/BasicMessagesModule.ts @@ -1,15 +1,42 @@ +import type { BasicMessagesModuleConfigOptions } from './BasicMessagesModuleConfig' +import type { BasicMessageProtocol } from './protocols/BasicMessageProtocol' import type { FeatureRegistry } from '../../agent/FeatureRegistry' -import type { DependencyManager, Module } from '../../plugins' +import type { ApiModule, DependencyManager } from '../../plugins' +import type { Optional } from '../../utils' +import type { Constructor } from '../../utils/mixins' -import { Protocol } from '../../agent/models' - -import { BasicMessageRole } from './BasicMessageRole' import { BasicMessagesApi } from './BasicMessagesApi' +import { BasicMessagesModuleConfig } from './BasicMessagesModuleConfig' +import { V1BasicMessageProtocol, V2BasicMessageProtocol } from './protocols' import { BasicMessageRepository } from './repository' -import { BasicMessageService } from './services' -export class BasicMessagesModule implements Module { - public readonly api = BasicMessagesApi +/** + * Default basicMessageProtocols that will be registered if the `basicMessageProtocols` property is not configured. + */ +export type DefaultBasicMessageProtocols = [] + +// BasicMessagesModuleOptions makes the credentialProtocols property optional from the config, as it will set it when not provided. +export type BasicMessagesModuleOptions = Optional< + BasicMessagesModuleConfigOptions, + 'basicMessageProtocols' +> + +export class BasicMessagesModule + implements ApiModule +{ + public readonly config: BasicMessagesModuleConfig + + public readonly api: Constructor> = BasicMessagesApi + + public constructor(config?: BasicMessagesModuleConfig) { + this.config = new BasicMessagesModuleConfig({ + ...config, + basicMessageProtocols: config?.basicMessageProtocols ?? [ + new V1BasicMessageProtocol(), + new V2BasicMessageProtocol(), + ], + } as BasicMessagesModuleConfig) + } /** * Registers the dependencies of the basic message module on the dependency manager. @@ -18,18 +45,15 @@ export class BasicMessagesModule implements Module { // Api dependencyManager.registerContextScoped(BasicMessagesApi) - // Services - dependencyManager.registerSingleton(BasicMessageService) + // Config + dependencyManager.registerInstance(BasicMessagesModuleConfig, this.config) // Repositories dependencyManager.registerSingleton(BasicMessageRepository) - // Features - featureRegistry.register( - new Protocol({ - id: 'https://didcomm.org/basicmessage/1.0', - roles: [BasicMessageRole.Sender, BasicMessageRole.Receiver], - }) - ) + // Protocol needs to register feature registry items and handlers + for (const basicMessageProtocols of this.config.basicMessageProtocols) { + basicMessageProtocols.register(dependencyManager, featureRegistry) + } } } diff --git a/packages/core/src/modules/basic-messages/BasicMessagesModuleConfig.ts b/packages/core/src/modules/basic-messages/BasicMessagesModuleConfig.ts new file mode 100644 index 0000000000..d87f2d9854 --- /dev/null +++ b/packages/core/src/modules/basic-messages/BasicMessagesModuleConfig.ts @@ -0,0 +1,32 @@ +import type { BasicMessageProtocol } from './protocols/BasicMessageProtocol' + +/** + * CredentialsModuleConfigOptions defines the interface for the options of the CredentialsModuleConfig class. + * This can contain optional parameters that have default values in the config class itself. + */ +export interface BasicMessagesModuleConfigOptions { + /** + * Protocols to make available to the module. + * + * When not provided, both `V1BasicMessageProtocol` and `V2BasicMessageProtocol` are registered by default. + * + * @default + * ``` + * [V1BasicMessageProtocol, V2BasicMessageProtocol] + * ``` + */ + basicMessageProtocols: BasicMessageProtocols +} + +export class BasicMessagesModuleConfig { + private options: BasicMessagesModuleConfigOptions + + public constructor(options: BasicMessagesModuleConfigOptions) { + this.options = options + } + + /** See {@link BasicMessagesModuleConfigOptions.basicMessageProtocols} */ + public get basicMessageProtocols() { + return this.options.basicMessageProtocols + } +} diff --git a/packages/core/src/modules/basic-messages/__tests__/BasicMessagesModule.test.ts b/packages/core/src/modules/basic-messages/__tests__/BasicMessagesModule.test.ts index 4a9f106810..8fd849e156 100644 --- a/packages/core/src/modules/basic-messages/__tests__/BasicMessagesModule.test.ts +++ b/packages/core/src/modules/basic-messages/__tests__/BasicMessagesModule.test.ts @@ -2,8 +2,8 @@ import { FeatureRegistry } from '../../../agent/FeatureRegistry' import { DependencyManager } from '../../../plugins/DependencyManager' import { BasicMessagesApi } from '../BasicMessagesApi' import { BasicMessagesModule } from '../BasicMessagesModule' +import { BasicMessagesModuleConfig } from '../BasicMessagesModuleConfig' import { BasicMessageRepository } from '../repository' -import { BasicMessageService } from '../services' jest.mock('../../../plugins/DependencyManager') const DependencyManagerMock = DependencyManager as jest.Mock @@ -17,13 +17,16 @@ const featureRegistry = new FeatureRegistryMock() describe('BasicMessagesModule', () => { test('registers dependencies on the dependency manager', () => { - new BasicMessagesModule().register(dependencyManager, featureRegistry) + const module = new BasicMessagesModule() + module.register(dependencyManager, featureRegistry) expect(dependencyManager.registerContextScoped).toHaveBeenCalledTimes(1) expect(dependencyManager.registerContextScoped).toHaveBeenCalledWith(BasicMessagesApi) - expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(2) - expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(BasicMessageService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(1) expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(BasicMessageRepository) + + expect(dependencyManager.registerInstance).toHaveBeenCalledTimes(1) + expect(dependencyManager.registerInstance).toHaveBeenCalledWith(BasicMessagesModuleConfig, module.config) }) }) diff --git a/packages/core/src/modules/basic-messages/__tests__/basic-messages.e2e.test.ts b/packages/core/src/modules/basic-messages/__tests__/basic-messages.e2e.test.ts index a18aa98754..2f14ca7353 100644 --- a/packages/core/src/modules/basic-messages/__tests__/basic-messages.e2e.test.ts +++ b/packages/core/src/modules/basic-messages/__tests__/basic-messages.e2e.test.ts @@ -1,25 +1,29 @@ /* eslint-disable @typescript-eslint/no-non-null-assertion */ import type { SubjectMessage } from '../../../../../../tests/transport/SubjectInboundTransport' import type { ConnectionRecord } from '../../../modules/connections' +import type { V2BasicMessage } from '../protocols' import { Subject } from 'rxjs' +import { describeRunInNodeVersion } from '../../../../../../tests/runInVersion' import { SubjectInboundTransport } from '../../../../../../tests/transport/SubjectInboundTransport' import { SubjectOutboundTransport } from '../../../../../../tests/transport/SubjectOutboundTransport' -import { getIndySdkModules } from '../../../../../indy-sdk/tests/setupIndySdkModule' +import { getAskarAnonCredsIndyModules } from '../../../../../anoncreds/tests/legacyAnonCredsSetup' import { getAgentOptions, makeConnection, waitForBasicMessage } from '../../../../tests/helpers' import testLogger from '../../../../tests/logger' import { Agent } from '../../../agent/Agent' import { MessageSendingError, RecordNotFoundError } from '../../../error' -import { BasicMessage } from '../messages' +import { OutOfBandVersion } from '../../oob' +import { V1BasicMessage } from '../protocols' import { BasicMessageRecord } from '../repository' const faberConfig = getAgentOptions( 'Faber Basic Messages', { endpoints: ['rxjs:faber'], + logger: testLogger, }, - getIndySdkModules() + getAskarAnonCredsIndyModules() ) const aliceConfig = getAgentOptions( @@ -27,21 +31,35 @@ const aliceConfig = getAgentOptions( { endpoints: ['rxjs:alice'], }, - getIndySdkModules() + getAskarAnonCredsIndyModules() ) -describe('Basic Messages E2E', () => { +const bobConfig = getAgentOptions( + 'Bob Basic Messages', + { + endpoints: ['rxjs:bob'], + logger: testLogger, + }, + getAskarAnonCredsIndyModules() +) + +describeRunInNodeVersion([18], 'Basic Messages E2E', () => { let faberAgent: Agent let aliceAgent: Agent + let bobAgent: Agent let faberConnection: ConnectionRecord let aliceConnection: ConnectionRecord + let bobConnection: ConnectionRecord + let faberBobConnection: ConnectionRecord beforeEach(async () => { const faberMessages = new Subject() const aliceMessages = new Subject() + const bobMessages = new Subject() const subjectMap = { 'rxjs:faber': faberMessages, 'rxjs:alice': aliceMessages, + 'rxjs:bob': bobMessages, } faberAgent = new Agent(faberConfig) @@ -54,6 +72,12 @@ describe('Basic Messages E2E', () => { aliceAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap)) await aliceAgent.initialize() ;[aliceConnection, faberConnection] = await makeConnection(aliceAgent, faberAgent) + + bobAgent = new Agent(bobConfig) + bobAgent.registerInboundTransport(new SubjectInboundTransport(bobMessages)) + bobAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap)) + await bobAgent.initialize() + ;[bobConnection, faberBobConnection] = await makeConnection(bobAgent, faberAgent, OutOfBandVersion.V2) }) afterEach(async () => { @@ -61,6 +85,8 @@ describe('Basic Messages E2E', () => { await faberAgent.wallet.delete() await aliceAgent.shutdown() await aliceAgent.wallet.delete() + await bobAgent.shutdown() + await bobAgent.wallet.delete() }) test('Alice and Faber exchange messages', async () => { @@ -105,7 +131,7 @@ describe('Basic Messages E2E', () => { content: 'How are you?', }) expect(replyMessage.content).toBe('How are you?') - expect(replyMessage.thread?.parentThreadId).toBe(helloMessage.id) + expect(replyMessage.parentThreadId).toBe(helloMessage.id) // Both sender and recipient shall be able to find the threaded messages // Hello message @@ -133,6 +159,59 @@ describe('Basic Messages E2E', () => { expect(faberReplyMessages[0]).toMatchObject(replyRecord) }) + test('Bob and Faber exchange messages using V2 protocol', async () => { + testLogger.test('Bob sends message to Faber') + const helloRecord = await bobAgent.basicMessages.sendMessage(bobConnection.id, 'Hello') + + expect(helloRecord.content).toBe('Hello') + + testLogger.test('Faber waits for message from Bob') + const helloMessage = await waitForBasicMessage(faberAgent, { + content: 'Hello', + }) + + testLogger.test('Faber sends message to Bob') + const replyRecord = await faberAgent.basicMessages.sendMessage( + faberBobConnection.id, + 'How are you?', + helloMessage.id + ) + expect(replyRecord.content).toBe('How are you?') + expect(replyRecord.parentThreadId).toBe(helloMessage.id) + + testLogger.test('Bob waits until he receives message from faber') + const replyMessage = (await waitForBasicMessage(bobAgent, { + content: 'How are you?', + })) as V2BasicMessage + expect(replyMessage.body.content).toBe('How are you?') + expect(replyMessage.parentThreadId).toBe(helloMessage.id) + + // Both sender and recipient shall be able to find the threaded messages + // Hello message + const bobHelloMessage = await bobAgent.basicMessages.getByThreadId(helloMessage.id) + const faberHelloMessage = await faberAgent.basicMessages.getByThreadId(helloMessage.id) + expect(bobHelloMessage).toMatchObject({ + content: helloRecord.content, + threadId: helloRecord.threadId, + }) + expect(faberHelloMessage).toMatchObject({ + content: helloRecord.content, + threadId: helloRecord.threadId, + }) + + // Reply message + const bobReplyMessages = await bobAgent.basicMessages.findAllByQuery({ parentThreadId: helloMessage.id }) + const faberReplyMessages = await faberAgent.basicMessages.findAllByQuery({ parentThreadId: helloMessage.id }) + expect(bobReplyMessages.length).toBe(1) + expect(bobReplyMessages[0]).toMatchObject({ + content: replyRecord.content, + parentThreadId: replyRecord.parentThreadId, + threadId: replyRecord.threadId, + }) + expect(faberReplyMessages.length).toBe(1) + expect(faberReplyMessages[0]).toMatchObject(replyRecord) + }) + test('Alice is unable to send a message', async () => { testLogger.test('Alice sends message to Faber that is undeliverable') @@ -151,8 +230,8 @@ describe('Basic Messages E2E', () => { testLogger.test('Error thrown includes the outbound message and recently created record id') expect(thrownError.outboundMessageContext.associatedRecord).toBeInstanceOf(BasicMessageRecord) - expect(thrownError.outboundMessageContext.message).toBeInstanceOf(BasicMessage) - expect((thrownError.outboundMessageContext.message as BasicMessage).content).toBe('Hello undeliverable') + expect(thrownError.outboundMessageContext.message).toBeInstanceOf(V1BasicMessage) + expect((thrownError.outboundMessageContext.message as V1BasicMessage).content).toBe('Hello undeliverable') testLogger.test('Created record can be found and deleted by id') const storedRecord = await aliceAgent.basicMessages.getById( diff --git a/packages/core/src/modules/basic-messages/handlers/BasicMessageHandler.ts b/packages/core/src/modules/basic-messages/handlers/BasicMessageHandler.ts deleted file mode 100644 index cec6931983..0000000000 --- a/packages/core/src/modules/basic-messages/handlers/BasicMessageHandler.ts +++ /dev/null @@ -1,18 +0,0 @@ -import type { MessageHandler, MessageHandlerInboundMessage } from '../../../agent/MessageHandler' -import type { BasicMessageService } from '../services/BasicMessageService' - -import { BasicMessage } from '../messages' - -export class BasicMessageHandler implements MessageHandler { - private basicMessageService: BasicMessageService - public supportedMessages = [BasicMessage] - - public constructor(basicMessageService: BasicMessageService) { - this.basicMessageService = basicMessageService - } - - public async handle(messageContext: MessageHandlerInboundMessage) { - const connection = messageContext.assertReadyConnection() - await this.basicMessageService.save(messageContext, connection) - } -} diff --git a/packages/core/src/modules/basic-messages/handlers/index.ts b/packages/core/src/modules/basic-messages/handlers/index.ts deleted file mode 100644 index 64f421dd88..0000000000 --- a/packages/core/src/modules/basic-messages/handlers/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './BasicMessageHandler' diff --git a/packages/core/src/modules/basic-messages/index.ts b/packages/core/src/modules/basic-messages/index.ts index e0ca5207d1..123c503837 100644 --- a/packages/core/src/modules/basic-messages/index.ts +++ b/packages/core/src/modules/basic-messages/index.ts @@ -1,7 +1,8 @@ -export * from './messages' -export * from './services' +export * from './protocols' export * from './repository' export * from './BasicMessageEvents' export * from './BasicMessagesApi' export * from './BasicMessageRole' export * from './BasicMessagesModule' + +export { V1BasicMessage as BasicMessage } from './protocols' diff --git a/packages/core/src/modules/basic-messages/messages/index.ts b/packages/core/src/modules/basic-messages/messages/index.ts deleted file mode 100644 index 40d57b1840..0000000000 --- a/packages/core/src/modules/basic-messages/messages/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './BasicMessage' diff --git a/packages/core/src/modules/basic-messages/protocols/BaseBasicMessageProtocol.ts b/packages/core/src/modules/basic-messages/protocols/BaseBasicMessageProtocol.ts new file mode 100644 index 0000000000..5ddbc08d08 --- /dev/null +++ b/packages/core/src/modules/basic-messages/protocols/BaseBasicMessageProtocol.ts @@ -0,0 +1,56 @@ +import type { BasicMessageProtocol } from './BasicMessageProtocol' +import type { BasicMessageProtocolMsgReturnType, CreateMessageOptions } from './BasicMessageProtocolOptions' +import type { AgentContext } from '../../../agent' +import type { AgentBaseMessage } from '../../../agent/AgentBaseMessage' +import type { FeatureRegistry } from '../../../agent/FeatureRegistry' +import type { InboundMessageContext } from '../../../agent/models/InboundMessageContext' +import type { DependencyManager } from '../../../plugins' +import type { Query } from '../../../storage/StorageService' +import type { ConnectionRecord } from '../../connections/repository/ConnectionRecord' +import type { BasicMessageRecord } from '../repository' + +import { BasicMessageRepository } from '../repository' + +export abstract class BaseBasicMessageProtocol implements BasicMessageProtocol { + public abstract readonly version: string + + public abstract createMessage( + agentContext: AgentContext, + options: CreateMessageOptions + ): Promise> + + /** + * @todo use connection from message context + */ + public abstract save( + { message, agentContext }: InboundMessageContext, + connection: ConnectionRecord + ): Promise + + public async findAllByQuery(agentContext: AgentContext, query: Query) { + const basicMessageRepository = agentContext.dependencyManager.resolve(BasicMessageRepository) + return basicMessageRepository.findByQuery(agentContext, query) + } + + public async getById(agentContext: AgentContext, basicMessageRecordId: string) { + const basicMessageRepository = agentContext.dependencyManager.resolve(BasicMessageRepository) + return basicMessageRepository.getById(agentContext, basicMessageRecordId) + } + + public async getByThreadId(agentContext: AgentContext, threadId: string) { + const basicMessageRepository = agentContext.dependencyManager.resolve(BasicMessageRepository) + return basicMessageRepository.getSingleByQuery(agentContext, { threadId }) + } + + public async findAllByParentThreadId(agentContext: AgentContext, parentThreadId: string) { + const basicMessageRepository = agentContext.dependencyManager.resolve(BasicMessageRepository) + return basicMessageRepository.findByQuery(agentContext, { parentThreadId }) + } + + public async deleteById(agentContext: AgentContext, basicMessageRecordId: string) { + const basicMessageRepository = agentContext.dependencyManager.resolve(BasicMessageRepository) + return basicMessageRepository.deleteById(agentContext, basicMessageRecordId) + } + + public abstract register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry): void +} diff --git a/packages/core/src/modules/basic-messages/protocols/BasicMessageProtocol.ts b/packages/core/src/modules/basic-messages/protocols/BasicMessageProtocol.ts new file mode 100644 index 0000000000..d34024e7b0 --- /dev/null +++ b/packages/core/src/modules/basic-messages/protocols/BasicMessageProtocol.ts @@ -0,0 +1,20 @@ +import type { BasicMessageProtocolMsgReturnType, CreateMessageOptions } from './BasicMessageProtocolOptions' +import type { AgentContext } from '../../../agent' +import type { AgentBaseMessage } from '../../../agent/AgentBaseMessage' +import type { FeatureRegistry } from '../../../agent/FeatureRegistry' +import type { InboundMessageContext } from '../../../agent/models/InboundMessageContext' +import type { DependencyManager } from '../../../plugins' +import type { ConnectionRecord } from '../../connections/repository/ConnectionRecord' + +export interface BasicMessageProtocol { + readonly version: string + + createMessage( + agentContext: AgentContext, + options: CreateMessageOptions + ): Promise> + + save({ message, agentContext }: InboundMessageContext, connection: ConnectionRecord): Promise + + register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry): void +} diff --git a/packages/core/src/modules/basic-messages/protocols/BasicMessageProtocolOptions.ts b/packages/core/src/modules/basic-messages/protocols/BasicMessageProtocolOptions.ts new file mode 100644 index 0000000000..b71d942346 --- /dev/null +++ b/packages/core/src/modules/basic-messages/protocols/BasicMessageProtocolOptions.ts @@ -0,0 +1,14 @@ +import type { AgentBaseMessage } from '../../../agent/AgentBaseMessage' +import type { ConnectionRecord } from '../../connections' +import type { BasicMessageRecord } from '../repository' + +export interface CreateMessageOptions { + connectionRecord: ConnectionRecord + content: string + parentThreadId?: string +} + +export interface BasicMessageProtocolMsgReturnType { + message: MessageType + record: BasicMessageRecord +} diff --git a/packages/core/src/modules/basic-messages/protocols/index.ts b/packages/core/src/modules/basic-messages/protocols/index.ts new file mode 100644 index 0000000000..4806c2a7b2 --- /dev/null +++ b/packages/core/src/modules/basic-messages/protocols/index.ts @@ -0,0 +1,4 @@ +export * from './v1' +export * from './v2' +export * from './BasicMessageProtocol' +export * from './BasicMessageProtocolOptions' diff --git a/packages/core/src/modules/basic-messages/protocols/v1/V1BasicMessageProtocol.ts b/packages/core/src/modules/basic-messages/protocols/v1/V1BasicMessageProtocol.ts new file mode 100644 index 0000000000..8ad13e3bfa --- /dev/null +++ b/packages/core/src/modules/basic-messages/protocols/v1/V1BasicMessageProtocol.ts @@ -0,0 +1,99 @@ +import type { AgentContext } from '../../../../agent' +import type { FeatureRegistry } from '../../../../agent/FeatureRegistry' +import type { InboundMessageContext } from '../../../../agent/models/InboundMessageContext' +import type { DependencyManager } from '../../../../plugins' +import type { ConnectionRecord } from '../../../connections/repository/ConnectionRecord' +import type { BasicMessageStateChangedEvent } from '../../BasicMessageEvents' +import type { CreateMessageOptions } from '../BasicMessageProtocolOptions' + +import { EventEmitter } from '../../../../agent/EventEmitter' +import { Protocol } from '../../../../agent/models' +import { injectable } from '../../../../plugins' +import { BasicMessageEventTypes } from '../../BasicMessageEvents' +import { BasicMessageRole } from '../../BasicMessageRole' +import { BasicMessageRecord, BasicMessageRepository } from '../../repository' +import { BaseBasicMessageProtocol } from '../BaseBasicMessageProtocol' + +import { V1BasicMessageHandler } from './handlers' +import { V1BasicMessage } from './messages' + +@injectable() +export class V1BasicMessageProtocol extends BaseBasicMessageProtocol { + /** + * The version of Basic Messages this class supports + */ + public readonly version = 'v1' as const + + /** + * Registers the protocol implementation (handlers, feature registry) on the agent. + */ + public register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry) { + // Register message handlers for Basic Message V1 Protocol + dependencyManager.registerMessageHandlers([new V1BasicMessageHandler(this)]) + + // Register in feature registry, with supported roles + featureRegistry.register( + new Protocol({ + id: 'https://didcomm.org/basicmessage/1.0', + roles: [BasicMessageRole.Sender, BasicMessageRole.Receiver], + }) + ) + } + + public async createMessage(agentContext: AgentContext, options: CreateMessageOptions) { + const { content, parentThreadId, connectionRecord } = options + const basicMessage = new V1BasicMessage({ content }) + + const basicMessageRepository = agentContext.dependencyManager.resolve(BasicMessageRepository) + + // If no parentThreadid is defined, there is no need to explicitly send a thread decorator + if (parentThreadId) { + basicMessage.setThread({ parentThreadId }) + } + + const basicMessageRecord = new BasicMessageRecord({ + sentTime: basicMessage.sentTime.toISOString(), + content: basicMessage.content, + connectionId: connectionRecord.id, + role: BasicMessageRole.Sender, + threadId: basicMessage.threadId, + parentThreadId, + }) + + await basicMessageRepository.save(agentContext, basicMessageRecord) + this.emitStateChangedEvent(agentContext, basicMessageRecord, basicMessage) + + return { message: basicMessage, record: basicMessageRecord } + } + + /** + * @todo use connection from message context + */ + public async save({ message, agentContext }: InboundMessageContext, connection: ConnectionRecord) { + const basicMessageRepository = agentContext.dependencyManager.resolve(BasicMessageRepository) + + const basicMessageRecord = new BasicMessageRecord({ + sentTime: message.sentTime.toISOString(), + content: message.content, + connectionId: connection.id, + role: BasicMessageRole.Receiver, + threadId: message.threadId, + parentThreadId: message.thread?.parentThreadId, + }) + + await basicMessageRepository.save(agentContext, basicMessageRecord) + this.emitStateChangedEvent(agentContext, basicMessageRecord, message) + } + + protected emitStateChangedEvent( + agentContext: AgentContext, + basicMessageRecord: BasicMessageRecord, + basicMessage: V1BasicMessage + ) { + const eventEmitter = agentContext.dependencyManager.resolve(EventEmitter) + eventEmitter.emit(agentContext, { + type: BasicMessageEventTypes.BasicMessageStateChanged, + payload: { message: basicMessage, basicMessageRecord: basicMessageRecord.clone() }, + }) + } +} diff --git a/packages/core/src/modules/basic-messages/__tests__/BasicMessageService.test.ts b/packages/core/src/modules/basic-messages/protocols/v1/__tests__/V1BasicMessageProtocol.test.ts similarity index 63% rename from packages/core/src/modules/basic-messages/__tests__/BasicMessageService.test.ts rename to packages/core/src/modules/basic-messages/protocols/v1/__tests__/V1BasicMessageProtocol.test.ts index 83dd0c4c01..a62a23aca9 100644 --- a/packages/core/src/modules/basic-messages/__tests__/BasicMessageService.test.ts +++ b/packages/core/src/modules/basic-messages/protocols/v1/__tests__/V1BasicMessageProtocol.test.ts @@ -1,36 +1,44 @@ -import { getAgentContext, getMockConnection } from '../../../../tests/helpers' -import { EventEmitter } from '../../../agent/EventEmitter' -import { InboundMessageContext } from '../../../agent/models/InboundMessageContext' -import { BasicMessageRole } from '../BasicMessageRole' -import { BasicMessage } from '../messages' -import { BasicMessageRecord } from '../repository/BasicMessageRecord' -import { BasicMessageRepository } from '../repository/BasicMessageRepository' -import { BasicMessageService } from '../services' +import { getAgentContext, getMockConnection } from '../../../../../../tests/helpers' +import { EventEmitter } from '../../../../../agent/EventEmitter' +import { InboundMessageContext } from '../../../../../agent/models/InboundMessageContext' +import { BasicMessageRole } from '../../../BasicMessageRole' +import { BasicMessageRecord } from '../../../repository/BasicMessageRecord' +import { BasicMessageRepository } from '../../../repository/BasicMessageRepository' +import { V1BasicMessageProtocol } from '../V1BasicMessageProtocol' +import { V1BasicMessage } from '../messages' -jest.mock('../repository/BasicMessageRepository') +jest.mock('../../../repository/BasicMessageRepository') const BasicMessageRepositoryMock = BasicMessageRepository as jest.Mock const basicMessageRepository = new BasicMessageRepositoryMock() -jest.mock('../../../agent/EventEmitter') +jest.mock('../../../../../agent/EventEmitter') const EventEmitterMock = EventEmitter as jest.Mock const eventEmitter = new EventEmitterMock() -const agentContext = getAgentContext() +const agentContext = getAgentContext({ + registerInstances: [ + [BasicMessageRepository, basicMessageRepository], + [EventEmitter, eventEmitter], + ], +}) -describe('BasicMessageService', () => { - let basicMessageService: BasicMessageService +describe('V1BasicMessageProtocol', () => { + let basicMessageProtocol: V1BasicMessageProtocol const mockConnectionRecord = getMockConnection({ id: 'd3849ac3-c981-455b-a1aa-a10bea6cead8', did: 'did:sov:C2SsBf5QUQpqSAQfhu3sd2', }) beforeEach(() => { - basicMessageService = new BasicMessageService(basicMessageRepository, eventEmitter) + basicMessageProtocol = new V1BasicMessageProtocol() }) describe('createMessage', () => { it(`creates message and record, and emits message and basic message record`, async () => { - const { message } = await basicMessageService.createMessage(agentContext, 'hello', mockConnectionRecord) + const { message } = await basicMessageProtocol.createMessage(agentContext, { + content: 'hello', + connectionRecord: mockConnectionRecord, + }) expect(message.content).toBe('hello') @@ -53,14 +61,14 @@ describe('BasicMessageService', () => { describe('save', () => { it(`stores record and emits message and basic message record`, async () => { - const basicMessage = new BasicMessage({ + const basicMessage = new V1BasicMessage({ id: '123', content: 'message', }) const messageContext = new InboundMessageContext(basicMessage, { agentContext }) - await basicMessageService.save(messageContext, mockConnectionRecord) + await basicMessageProtocol.save(messageContext, mockConnectionRecord) expect(basicMessageRepository.save).toHaveBeenCalledWith(agentContext, expect.any(BasicMessageRecord)) expect(eventEmitter.emit).toHaveBeenCalledWith(agentContext, { diff --git a/packages/core/src/modules/basic-messages/protocols/v1/handlers/V1BasicMessageHandler.ts b/packages/core/src/modules/basic-messages/protocols/v1/handlers/V1BasicMessageHandler.ts new file mode 100644 index 0000000000..d91428c3ef --- /dev/null +++ b/packages/core/src/modules/basic-messages/protocols/v1/handlers/V1BasicMessageHandler.ts @@ -0,0 +1,18 @@ +import type { MessageHandler, MessageHandlerInboundMessage } from '../../../../../agent/MessageHandler' +import type { V1BasicMessageProtocol } from '../V1BasicMessageProtocol' + +import { V1BasicMessage } from '../messages' + +export class V1BasicMessageHandler implements MessageHandler { + private basicMessageProtocol: V1BasicMessageProtocol + public supportedMessages = [V1BasicMessage] + + public constructor(basicMessageProtocol: V1BasicMessageProtocol) { + this.basicMessageProtocol = basicMessageProtocol + } + + public async handle(messageContext: MessageHandlerInboundMessage) { + const connection = messageContext.assertReadyConnection() + await this.basicMessageProtocol.save(messageContext, connection) + } +} diff --git a/packages/core/src/modules/basic-messages/protocols/v1/handlers/index.ts b/packages/core/src/modules/basic-messages/protocols/v1/handlers/index.ts new file mode 100644 index 0000000000..b7fa7fb3f2 --- /dev/null +++ b/packages/core/src/modules/basic-messages/protocols/v1/handlers/index.ts @@ -0,0 +1 @@ +export * from './V1BasicMessageHandler' diff --git a/packages/core/src/modules/basic-messages/protocols/v1/index.ts b/packages/core/src/modules/basic-messages/protocols/v1/index.ts new file mode 100644 index 0000000000..9e581eae0d --- /dev/null +++ b/packages/core/src/modules/basic-messages/protocols/v1/index.ts @@ -0,0 +1,3 @@ +export * from './V1BasicMessageProtocol' +export * from './handlers' +export * from './messages' diff --git a/packages/core/src/modules/basic-messages/messages/BasicMessage.ts b/packages/core/src/modules/basic-messages/protocols/v1/messages/V1BasicMessage.ts similarity index 71% rename from packages/core/src/modules/basic-messages/messages/BasicMessage.ts rename to packages/core/src/modules/basic-messages/protocols/v1/messages/V1BasicMessage.ts index 1b720dceec..cef62224a3 100644 --- a/packages/core/src/modules/basic-messages/messages/BasicMessage.ts +++ b/packages/core/src/modules/basic-messages/protocols/v1/messages/V1BasicMessage.ts @@ -1,11 +1,11 @@ import { Expose, Transform } from 'class-transformer' import { IsDate, IsString } from 'class-validator' -import { DidCommV1Message } from '../../../didcomm' -import { IsValidMessageType, parseMessageType } from '../../../utils/messageType' -import { DateParser } from '../../../utils/transformers' +import { DidCommV1Message } from '../../../../../didcomm' +import { IsValidMessageType, parseMessageType } from '../../../../../utils/messageType' +import { DateParser } from '../../../../../utils/transformers' -export class BasicMessage extends DidCommV1Message { +export class V1BasicMessage extends DidCommV1Message { public readonly allowDidSovPrefix = true /** @@ -24,8 +24,8 @@ export class BasicMessage extends DidCommV1Message { } } - @IsValidMessageType(BasicMessage.type) - public readonly type = BasicMessage.type.messageTypeUri + @IsValidMessageType(V1BasicMessage.type) + public readonly type = V1BasicMessage.type.messageTypeUri public static readonly type = parseMessageType('https://didcomm.org/basicmessage/1.0/message') @Expose({ name: 'sent_time' }) diff --git a/packages/core/src/modules/basic-messages/protocols/v1/messages/index.ts b/packages/core/src/modules/basic-messages/protocols/v1/messages/index.ts new file mode 100644 index 0000000000..0e2b67bf45 --- /dev/null +++ b/packages/core/src/modules/basic-messages/protocols/v1/messages/index.ts @@ -0,0 +1 @@ +export * from './V1BasicMessage' diff --git a/packages/core/src/modules/basic-messages/protocols/v2/V2BasicMessageProtocol.ts b/packages/core/src/modules/basic-messages/protocols/v2/V2BasicMessageProtocol.ts new file mode 100644 index 0000000000..a2dcc38855 --- /dev/null +++ b/packages/core/src/modules/basic-messages/protocols/v2/V2BasicMessageProtocol.ts @@ -0,0 +1,84 @@ +import type { AgentContext } from '../../../../agent' +import type { FeatureRegistry } from '../../../../agent/FeatureRegistry' +import type { InboundMessageContext } from '../../../../agent/models/InboundMessageContext' +import type { DependencyManager } from '../../../../plugins' +import type { ConnectionRecord } from '../../../connections/repository/ConnectionRecord' +import type { CreateMessageOptions } from '../BasicMessageProtocolOptions' + +import { Protocol } from '../../../../agent/models' +import { injectable } from '../../../../plugins' +import { BasicMessageRole } from '../../BasicMessageRole' +import { BasicMessageRecord, BasicMessageRepository } from '../../repository' +import { BaseBasicMessageProtocol } from '../BaseBasicMessageProtocol' + +import { V2BasicMessageHandler } from './handlers' +import { V2BasicMessage } from './messages' + +@injectable() +export class V2BasicMessageProtocol extends BaseBasicMessageProtocol { + /** + * The version of Basic Messages this class supports + */ + public readonly version = 'v2' as const + + /** + * Registers the protocol implementation (handlers, feature registry) on the agent. + */ + public register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry) { + // Register message handlers for Basic Message V2 Protocol + dependencyManager.registerMessageHandlers([new V2BasicMessageHandler(this)]) + + // Register in feature registry, with supported roles + featureRegistry.register( + new Protocol({ + id: 'https://didcomm.org/basicmessage/2.0', + roles: [BasicMessageRole.Sender, BasicMessageRole.Receiver], + }) + ) + } + + public async createMessage(agentContext: AgentContext, options: CreateMessageOptions) { + const { content, parentThreadId, connectionRecord } = options + const basicMessage = new V2BasicMessage({ content }) + + const basicMessageRepository = agentContext.dependencyManager.resolve(BasicMessageRepository) + + // If no parentThreadid is defined, there is no need to explicitly send a thread decorator + if (parentThreadId) { + basicMessage.parentThreadId = parentThreadId + } + + const basicMessageRecord = new BasicMessageRecord({ + sentTime: new Date(basicMessage.createdTime).toISOString(), + content: basicMessage.body.content, + connectionId: connectionRecord.id, + role: BasicMessageRole.Sender, + threadId: basicMessage.threadId, + parentThreadId, + }) + + await basicMessageRepository.save(agentContext, basicMessageRecord) + // TODO: Emit BasicStateChangedEvent when it is updated to accept V2 Basic Messages + + return { message: basicMessage, record: basicMessageRecord } + } + + /** + * @todo use connection from message context + */ + public async save({ message, agentContext }: InboundMessageContext, connection: ConnectionRecord) { + const basicMessageRepository = agentContext.dependencyManager.resolve(BasicMessageRepository) + + const basicMessageRecord = new BasicMessageRecord({ + sentTime: new Date(message.createdTime).toISOString(), + content: message.body.content, + connectionId: connection.id, + role: BasicMessageRole.Receiver, + threadId: message.threadId, + parentThreadId: message.parentThreadId, + }) + + await basicMessageRepository.save(agentContext, basicMessageRecord) + // TODO: Emit BasicStateChangedEvent when it is updated to accept V2 Basic Messages + } +} diff --git a/packages/core/src/modules/basic-messages/protocols/v2/__tests__/V2BasicMessageProtocol.test.ts b/packages/core/src/modules/basic-messages/protocols/v2/__tests__/V2BasicMessageProtocol.test.ts new file mode 100644 index 0000000000..2420a61884 --- /dev/null +++ b/packages/core/src/modules/basic-messages/protocols/v2/__tests__/V2BasicMessageProtocol.test.ts @@ -0,0 +1,89 @@ +import { getAgentContext, getMockConnection } from '../../../../../../tests/helpers' +import { EventEmitter } from '../../../../../agent/EventEmitter' +import { InboundMessageContext } from '../../../../../agent/models/InboundMessageContext' +import { BasicMessageRole } from '../../../BasicMessageRole' +import { BasicMessageRecord } from '../../../repository/BasicMessageRecord' +import { BasicMessageRepository } from '../../../repository/BasicMessageRepository' +import { V2BasicMessageProtocol } from '../V2BasicMessageProtocol' +import { V2BasicMessage } from '../messages' + +jest.mock('../../../repository/BasicMessageRepository') +const BasicMessageRepositoryMock = BasicMessageRepository as jest.Mock +const basicMessageRepository = new BasicMessageRepositoryMock() + +jest.mock('../../../../../agent/EventEmitter') +const EventEmitterMock = EventEmitter as jest.Mock +const eventEmitter = new EventEmitterMock() + +const agentContext = getAgentContext({ + registerInstances: [ + [BasicMessageRepository, basicMessageRepository], + [EventEmitter, eventEmitter], + ], +}) + +describe('V2BasicMessageProtocol', () => { + let basicMessageProtocol: V2BasicMessageProtocol + const mockConnectionRecord = getMockConnection({ + id: 'd3849ac3-c981-455b-a1aa-a10bea6cead8', + did: 'did:sov:C2SsBf5QUQpqSAQfhu3sd2', + }) + + beforeEach(() => { + basicMessageProtocol = new V2BasicMessageProtocol() + }) + + describe('createMessage', () => { + it(`creates message and record, and emits message and basic message record`, async () => { + const { message } = await basicMessageProtocol.createMessage(agentContext, { + content: 'hello', + connectionRecord: mockConnectionRecord, + }) + + expect(message.body.content).toBe('hello') + + expect(basicMessageRepository.save).toHaveBeenCalledWith(agentContext, expect.any(BasicMessageRecord)) + expect(eventEmitter.emit).not.toHaveBeenCalledWith(agentContext, { + type: 'BasicMessageStateChanged', + payload: { + basicMessageRecord: expect.objectContaining({ + connectionId: mockConnectionRecord.id, + id: expect.any(String), + sentTime: expect.any(String), + content: 'hello', + role: BasicMessageRole.Sender, + }), + message, + }, + }) + }) + }) + + describe('save', () => { + it(`stores record and emits message and basic message record`, async () => { + const basicMessage = new V2BasicMessage({ + id: '123', + content: 'message', + }) + + const messageContext = new InboundMessageContext(basicMessage, { agentContext }) + + await basicMessageProtocol.save(messageContext, mockConnectionRecord) + + expect(basicMessageRepository.save).toHaveBeenCalledWith(agentContext, expect.any(BasicMessageRecord)) + expect(eventEmitter.emit).not.toHaveBeenCalledWith(agentContext, { + type: 'BasicMessageStateChanged', + payload: { + basicMessageRecord: expect.objectContaining({ + connectionId: mockConnectionRecord.id, + id: expect.any(String), + sentTime: new Date(basicMessage.createdTime).toISOString(), + content: basicMessage.body.content, + role: BasicMessageRole.Receiver, + }), + message: messageContext.message, + }, + }) + }) + }) +}) diff --git a/packages/core/src/modules/basic-messages/protocols/v2/handlers/V2BasicMessageHandler.ts b/packages/core/src/modules/basic-messages/protocols/v2/handlers/V2BasicMessageHandler.ts new file mode 100644 index 0000000000..2995d643b5 --- /dev/null +++ b/packages/core/src/modules/basic-messages/protocols/v2/handlers/V2BasicMessageHandler.ts @@ -0,0 +1,18 @@ +import type { MessageHandler, MessageHandlerInboundMessage } from '../../../../../agent/MessageHandler' +import type { V2BasicMessageProtocol } from '../V2BasicMessageProtocol' + +import { V2BasicMessage } from '../messages' + +export class V2BasicMessageHandler implements MessageHandler { + private basicMessageProtocol: V2BasicMessageProtocol + public supportedMessages = [V2BasicMessage] + + public constructor(basicMessageProtocol: V2BasicMessageProtocol) { + this.basicMessageProtocol = basicMessageProtocol + } + + public async handle(messageContext: MessageHandlerInboundMessage) { + const connection = messageContext.assertReadyConnection() + await this.basicMessageProtocol.save(messageContext, connection) + } +} diff --git a/packages/core/src/modules/basic-messages/protocols/v2/handlers/index.ts b/packages/core/src/modules/basic-messages/protocols/v2/handlers/index.ts new file mode 100644 index 0000000000..a35412c989 --- /dev/null +++ b/packages/core/src/modules/basic-messages/protocols/v2/handlers/index.ts @@ -0,0 +1 @@ +export * from './V2BasicMessageHandler' diff --git a/packages/core/src/modules/basic-messages/protocols/v2/index.ts b/packages/core/src/modules/basic-messages/protocols/v2/index.ts new file mode 100644 index 0000000000..ca75f6a6e8 --- /dev/null +++ b/packages/core/src/modules/basic-messages/protocols/v2/index.ts @@ -0,0 +1,3 @@ +export * from './V2BasicMessageProtocol' +export * from './handlers' +export * from './messages' diff --git a/packages/core/src/modules/basic-messages/protocols/v2/messages/V2BasicMessage.ts b/packages/core/src/modules/basic-messages/protocols/v2/messages/V2BasicMessage.ts new file mode 100644 index 0000000000..7429e3dee0 --- /dev/null +++ b/packages/core/src/modules/basic-messages/protocols/v2/messages/V2BasicMessage.ts @@ -0,0 +1,51 @@ +import { Type } from 'class-transformer' +import { IsNotEmpty, IsObject, IsString, ValidateNested } from 'class-validator' + +import { DidCommV2Message } from '../../../../../didcomm' +import { IsValidMessageType, parseMessageType } from '../../../../../utils/messageType' + +class V2BasicMessageBody { + public constructor(options: { content: string }) { + if (options) { + this.content = options.content + } + } + @IsString() + public content!: string +} + +export class V2BasicMessage extends DidCommV2Message { + public readonly allowDidSovPrefix = false + + @IsNotEmpty() + public createdTime!: number + + @IsObject() + @ValidateNested() + @Type(() => V2BasicMessageBody) + public body!: V2BasicMessageBody + + /** + * Create new BasicMessage instance. + * sentTime will be assigned to new Date if not passed, id will be assigned to uuid/v4 if not passed + * @param options + */ + public constructor(options: { content: string; sentTime?: Date; id?: string; locale?: string }) { + super() + + if (options) { + this.id = options.id || this.generateId() + this.createdTime = options.sentTime?.getTime() || new Date().getTime() + this.body = new V2BasicMessageBody({ content: options.content }) + this.language = options.locale || 'en' + } + } + + public get content() { + return this.body.content + } + + @IsValidMessageType(V2BasicMessage.type) + public readonly type = V2BasicMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/basicmessage/2.0/message') +} diff --git a/packages/core/src/modules/basic-messages/protocols/v2/messages/index.ts b/packages/core/src/modules/basic-messages/protocols/v2/messages/index.ts new file mode 100644 index 0000000000..171b735eb1 --- /dev/null +++ b/packages/core/src/modules/basic-messages/protocols/v2/messages/index.ts @@ -0,0 +1 @@ +export * from './V2BasicMessage' diff --git a/packages/core/src/modules/basic-messages/services/BasicMessageService.ts b/packages/core/src/modules/basic-messages/services/BasicMessageService.ts deleted file mode 100644 index d58ec06564..0000000000 --- a/packages/core/src/modules/basic-messages/services/BasicMessageService.ts +++ /dev/null @@ -1,100 +0,0 @@ -import type { AgentContext } from '../../../agent' -import type { InboundMessageContext } from '../../../agent/models/InboundMessageContext' -import type { Query } from '../../../storage/StorageService' -import type { ConnectionRecord } from '../../connections/repository/ConnectionRecord' -import type { BasicMessageStateChangedEvent } from '../BasicMessageEvents' - -import { EventEmitter } from '../../../agent/EventEmitter' -import { injectable } from '../../../plugins' -import { BasicMessageEventTypes } from '../BasicMessageEvents' -import { BasicMessageRole } from '../BasicMessageRole' -import { BasicMessage } from '../messages' -import { BasicMessageRecord, BasicMessageRepository } from '../repository' - -@injectable() -export class BasicMessageService { - private basicMessageRepository: BasicMessageRepository - private eventEmitter: EventEmitter - - public constructor(basicMessageRepository: BasicMessageRepository, eventEmitter: EventEmitter) { - this.basicMessageRepository = basicMessageRepository - this.eventEmitter = eventEmitter - } - - public async createMessage( - agentContext: AgentContext, - message: string, - connectionRecord: ConnectionRecord, - parentThreadId?: string - ) { - const basicMessage = new BasicMessage({ content: message }) - - // If no parentThreadid is defined, there is no need to explicitly send a thread decorator - if (parentThreadId) { - basicMessage.setThread({ parentThreadId }) - } - - const basicMessageRecord = new BasicMessageRecord({ - sentTime: basicMessage.sentTime.toISOString(), - content: basicMessage.content, - connectionId: connectionRecord.id, - role: BasicMessageRole.Sender, - threadId: basicMessage.threadId, - parentThreadId, - }) - - await this.basicMessageRepository.save(agentContext, basicMessageRecord) - this.emitStateChangedEvent(agentContext, basicMessageRecord, basicMessage) - - return { message: basicMessage, record: basicMessageRecord } - } - - /** - * @todo use connection from message context - */ - public async save({ message, agentContext }: InboundMessageContext, connection: ConnectionRecord) { - const basicMessageRecord = new BasicMessageRecord({ - sentTime: message.sentTime.toISOString(), - content: message.content, - connectionId: connection.id, - role: BasicMessageRole.Receiver, - threadId: message.threadId, - parentThreadId: message.thread?.parentThreadId, - }) - - await this.basicMessageRepository.save(agentContext, basicMessageRecord) - this.emitStateChangedEvent(agentContext, basicMessageRecord, message) - } - - private emitStateChangedEvent( - agentContext: AgentContext, - basicMessageRecord: BasicMessageRecord, - basicMessage: BasicMessage - ) { - this.eventEmitter.emit(agentContext, { - type: BasicMessageEventTypes.BasicMessageStateChanged, - payload: { message: basicMessage, basicMessageRecord: basicMessageRecord.clone() }, - }) - } - - public async findAllByQuery(agentContext: AgentContext, query: Query) { - return this.basicMessageRepository.findByQuery(agentContext, query) - } - - public async getById(agentContext: AgentContext, basicMessageRecordId: string) { - return this.basicMessageRepository.getById(agentContext, basicMessageRecordId) - } - - public async getByThreadId(agentContext: AgentContext, threadId: string) { - return this.basicMessageRepository.getSingleByQuery(agentContext, { threadId }) - } - - public async findAllByParentThreadId(agentContext: AgentContext, parentThreadId: string) { - return this.basicMessageRepository.findByQuery(agentContext, { parentThreadId }) - } - - public async deleteById(agentContext: AgentContext, basicMessageRecordId: string) { - const basicMessageRecord = await this.getById(agentContext, basicMessageRecordId) - return this.basicMessageRepository.delete(agentContext, basicMessageRecord) - } -} diff --git a/packages/core/src/modules/basic-messages/services/index.ts b/packages/core/src/modules/basic-messages/services/index.ts deleted file mode 100644 index a48826839a..0000000000 --- a/packages/core/src/modules/basic-messages/services/index.ts +++ /dev/null @@ -1 +0,0 @@ -export * from './BasicMessageService' diff --git a/packages/core/src/modules/connections/services/ConnectionService.ts b/packages/core/src/modules/connections/services/ConnectionService.ts index 5292eb8ed4..ca7a1ef527 100644 --- a/packages/core/src/modules/connections/services/ConnectionService.ts +++ b/packages/core/src/modules/connections/services/ConnectionService.ts @@ -474,8 +474,7 @@ export class ConnectionService { const senderKey = messageContext.senderKey && messageContext.senderKey.publicKeyBase58 // set theirService to the value of lastReceivedMessage.service - let theirService = - messageContext.message?.service?.resolvedDidCommService ?? lastReceivedMessage?.service?.resolvedDidCommService + let theirService = message.service?.resolvedDidCommService ?? lastReceivedMessage?.service?.resolvedDidCommService let ourService = lastSentMessage?.service?.resolvedDidCommService // 1. check if there's an oob record associated. diff --git a/packages/core/src/modules/credentials/CredentialsApi.ts b/packages/core/src/modules/credentials/CredentialsApi.ts index d061eb4f14..7f53c4231a 100644 --- a/packages/core/src/modules/credentials/CredentialsApi.ts +++ b/packages/core/src/modules/credentials/CredentialsApi.ts @@ -19,13 +19,13 @@ import type { import type { CredentialProtocol } from './protocol/CredentialProtocol' import type { CredentialFormatsFromProtocols } from './protocol/CredentialProtocolOptions' import type { CredentialExchangeRecord } from './repository/CredentialExchangeRecord' -import type { DidCommV1Message } from '../../didcomm/versions/v1' import type { Query } from '../../storage/StorageService' import { AgentContext } from '../../agent' import { MessageSender } from '../../agent/MessageSender' import { getOutboundMessageContext } from '../../agent/getOutboundMessageContext' import { InjectionSymbols } from '../../constants' +import { isDidCommV1Message, type DidCommV1Message } from '../../didcomm/versions/v1' import { AriesFrameworkError } from '../../error' import { Logger } from '../../logger' import { inject, injectable } from '../../plugins' @@ -384,6 +384,10 @@ export class CredentialsApi implements Credent this.logger.debug('Offer Message successfully created', { message }) + if (!isDidCommV1Message(message)) { + throw new AriesFrameworkError('out-of-band credential offer is only supported for DIDComm V1') + } + return { message, credentialRecord } } diff --git a/packages/core/src/modules/credentials/CredentialsModule.ts b/packages/core/src/modules/credentials/CredentialsModule.ts index 9cae75eb28..c36b799999 100644 --- a/packages/core/src/modules/credentials/CredentialsModule.ts +++ b/packages/core/src/modules/credentials/CredentialsModule.ts @@ -9,8 +9,8 @@ import { Protocol } from '../../agent/models' import { CredentialsApi } from './CredentialsApi' import { CredentialsModuleConfig } from './CredentialsModuleConfig' +import { V2CredentialProtocol, V3CredentialProtocol } from './protocol' import { RevocationNotificationService } from './protocol/revocation-notification/services' -import { V2CredentialProtocol } from './protocol/v2' import { CredentialRepository } from './repository' /** @@ -37,7 +37,10 @@ export class CredentialsModule } diff --git a/packages/core/src/modules/credentials/CredentialsModuleConfig.ts b/packages/core/src/modules/credentials/CredentialsModuleConfig.ts index e6d23909ed..61df19ea1e 100644 --- a/packages/core/src/modules/credentials/CredentialsModuleConfig.ts +++ b/packages/core/src/modules/credentials/CredentialsModuleConfig.ts @@ -18,11 +18,11 @@ export interface CredentialsModuleConfigOptions { test('registers V2CredentialProtocol if no credentialProtocols are configured', () => { const credentialsModule = new CredentialsModule() - expect(credentialsModule.config.credentialProtocols).toEqual([expect.any(V2CredentialProtocol)]) + expect(credentialsModule.config.credentialProtocols).toEqual([ + expect.any(V2CredentialProtocol), + expect.any(V3CredentialProtocol), + ]) }) test('calls register on the provided CredentialProtocols', () => { diff --git a/packages/core/src/modules/credentials/formats/jsonld/JsonLdCredentialFormatService.ts b/packages/core/src/modules/credentials/formats/jsonld/JsonLdCredentialFormatService.ts index dc3e9b9646..ba934048c2 100644 --- a/packages/core/src/modules/credentials/formats/jsonld/JsonLdCredentialFormatService.ts +++ b/packages/core/src/modules/credentials/formats/jsonld/JsonLdCredentialFormatService.ts @@ -5,6 +5,7 @@ import type { JsonLdFormatDataVerifiableCredential, } from './JsonLdCredentialFormat' import type { AgentContext } from '../../../../agent' +import type { V2Attachment } from '../../../../decorators/attachment' import type { CredentialFormatService } from '../CredentialFormatService' import type { CredentialFormatAcceptOfferOptions, @@ -385,7 +386,7 @@ export class JsonLdCredentialFormatService implements CredentialFormatService { + public areCredentialsEqual = (message1: Attachment | V2Attachment, message2: Attachment | V2Attachment): boolean => { const obj1 = message1.getDataAsJson() const obj2 = message2.getDataAsJson() diff --git a/packages/core/src/modules/credentials/protocol/BaseCredentialProtocol.ts b/packages/core/src/modules/credentials/protocol/BaseCredentialProtocol.ts index 92692107fd..1a7a7d6da6 100644 --- a/packages/core/src/modules/credentials/protocol/BaseCredentialProtocol.ts +++ b/packages/core/src/modules/credentials/protocol/BaseCredentialProtocol.ts @@ -15,17 +15,18 @@ import type { CreateCredentialProblemReportOptions, } from './CredentialProtocolOptions' import type { AgentContext } from '../../../agent' +import type { AgentBaseMessage } from '../../../agent/AgentBaseMessage' import type { FeatureRegistry } from '../../../agent/FeatureRegistry' import type { InboundMessageContext } from '../../../agent/models/InboundMessageContext' -import type { DidCommV1Message } from '../../../didcomm' import type { DependencyManager } from '../../../plugins' import type { Query } from '../../../storage/StorageService' -import type { ProblemReportMessage } from '../../problem-reports' +import type { ProblemReportMessage, V2ProblemReportMessage } from '../../problem-reports' import type { CredentialStateChangedEvent } from '../CredentialEvents' import type { CredentialFormatService, ExtractCredentialFormats } from '../formats' import type { CredentialExchangeRecord } from '../repository' import { EventEmitter } from '../../../agent/EventEmitter' +import { DidCommV1Message } from '../../../didcomm' import { DidCommMessageRepository } from '../../../storage' import { CredentialEventTypes } from '../CredentialEvents' import { CredentialState } from '../models/CredentialState' @@ -46,83 +47,83 @@ export abstract class BaseCredentialProtocol - ): Promise> + ): Promise> public abstract processProposal( - messageContext: InboundMessageContext + messageContext: InboundMessageContext ): Promise public abstract acceptProposal( agentContext: AgentContext, options: AcceptCredentialProposalOptions - ): Promise> + ): Promise> public abstract negotiateProposal( agentContext: AgentContext, options: NegotiateCredentialProposalOptions - ): Promise> + ): Promise> // methods for offer public abstract createOffer( agentContext: AgentContext, options: CreateCredentialOfferOptions - ): Promise> + ): Promise> public abstract processOffer( - messageContext: InboundMessageContext + messageContext: InboundMessageContext ): Promise public abstract acceptOffer( agentContext: AgentContext, options: AcceptCredentialOfferOptions - ): Promise> + ): Promise> public abstract negotiateOffer( agentContext: AgentContext, options: NegotiateCredentialOfferOptions - ): Promise> + ): Promise> // methods for request public abstract createRequest( agentContext: AgentContext, options: CreateCredentialRequestOptions - ): Promise> + ): Promise> public abstract processRequest( - messageContext: InboundMessageContext + messageContext: InboundMessageContext ): Promise public abstract acceptRequest( agentContext: AgentContext, options: AcceptCredentialRequestOptions - ): Promise> + ): Promise> // methods for issue public abstract processCredential( - messageContext: InboundMessageContext + messageContext: InboundMessageContext ): Promise public abstract acceptCredential( agentContext: AgentContext, options: AcceptCredentialOptions - ): Promise> + ): Promise> // methods for ack - public abstract processAck(messageContext: InboundMessageContext): Promise + public abstract processAck(messageContext: InboundMessageContext): Promise // methods for problem-report public abstract createProblemReport( agentContext: AgentContext, options: CreateCredentialProblemReportOptions - ): Promise> + ): Promise> public abstract findProposalMessage( agentContext: AgentContext, credentialExchangeId: string - ): Promise + ): Promise public abstract findOfferMessage( agentContext: AgentContext, credentialExchangeId: string - ): Promise + ): Promise public abstract findRequestMessage( agentContext: AgentContext, credentialExchangeId: string - ): Promise + ): Promise public abstract findCredentialMessage( agentContext: AgentContext, credentialExchangeId: string - ): Promise + ): Promise public abstract getFormatData( agentContext: AgentContext, credentialExchangeId: string @@ -137,7 +138,7 @@ export abstract class BaseCredentialProtocol + messageContext: InboundMessageContext ): Promise { const { message: credentialProblemReportMessage, agentContext } = messageContext @@ -152,7 +153,10 @@ export abstract class BaseCredentialProtocol - ): Promise> - processProposal(messageContext: InboundMessageContext): Promise + ): Promise> + processProposal(messageContext: InboundMessageContext): Promise acceptProposal( agentContext: AgentContext, options: AcceptCredentialProposalOptions - ): Promise> + ): Promise> negotiateProposal( agentContext: AgentContext, options: NegotiateCredentialProposalOptions - ): Promise> + ): Promise> // methods for offer createOffer( agentContext: AgentContext, options: CreateCredentialOfferOptions - ): Promise> - processOffer(messageContext: InboundMessageContext): Promise + ): Promise> + processOffer(messageContext: InboundMessageContext): Promise acceptOffer( agentContext: AgentContext, options: AcceptCredentialOfferOptions - ): Promise> + ): Promise> negotiateOffer( agentContext: AgentContext, options: NegotiateCredentialOfferOptions - ): Promise> + ): Promise> // methods for request createRequest( agentContext: AgentContext, options: CreateCredentialRequestOptions - ): Promise> - processRequest(messageContext: InboundMessageContext): Promise + ): Promise> + processRequest(messageContext: InboundMessageContext): Promise acceptRequest( agentContext: AgentContext, options: AcceptCredentialRequestOptions - ): Promise> + ): Promise> // methods for issue - processCredential(messageContext: InboundMessageContext): Promise + processCredential(messageContext: InboundMessageContext): Promise acceptCredential( agentContext: AgentContext, options: AcceptCredentialOptions - ): Promise> + ): Promise> // methods for ack - processAck(messageContext: InboundMessageContext): Promise + processAck(messageContext: InboundMessageContext): Promise // methods for problem-report createProblemReport( agentContext: AgentContext, options: CreateCredentialProblemReportOptions - ): Promise> - processProblemReport(messageContext: InboundMessageContext): Promise + ): Promise> + processProblemReport( + messageContext: InboundMessageContext + ): Promise - findProposalMessage(agentContext: AgentContext, credentialExchangeId: string): Promise - findOfferMessage(agentContext: AgentContext, credentialExchangeId: string): Promise - findRequestMessage(agentContext: AgentContext, credentialExchangeId: string): Promise - findCredentialMessage(agentContext: AgentContext, credentialExchangeId: string): Promise + findProposalMessage(agentContext: AgentContext, credentialExchangeId: string): Promise + findOfferMessage(agentContext: AgentContext, credentialExchangeId: string): Promise + findRequestMessage(agentContext: AgentContext, credentialExchangeId: string): Promise + findCredentialMessage(agentContext: AgentContext, credentialExchangeId: string): Promise getFormatData( agentContext: AgentContext, credentialExchangeId: string diff --git a/packages/core/src/modules/credentials/protocol/CredentialProtocolOptions.ts b/packages/core/src/modules/credentials/protocol/CredentialProtocolOptions.ts index b8ba5b9761..63791679f6 100644 --- a/packages/core/src/modules/credentials/protocol/CredentialProtocolOptions.ts +++ b/packages/core/src/modules/credentials/protocol/CredentialProtocolOptions.ts @@ -1,5 +1,5 @@ import type { CredentialProtocol } from './CredentialProtocol' -import type { DidCommV1Message } from '../../../didcomm' +import type { AgentBaseMessage } from '../../../agent/AgentBaseMessage' import type { ConnectionRecord } from '../../connections/repository/ConnectionRecord' import type { CredentialFormat, @@ -155,7 +155,7 @@ export interface CreateCredentialProblemReportOptions { description: string } -export interface CredentialProtocolMsgReturnType { +export interface CredentialProtocolMsgReturnType { message: MessageType credentialRecord: CredentialExchangeRecord } diff --git a/packages/core/src/modules/credentials/protocol/index.ts b/packages/core/src/modules/credentials/protocol/index.ts index cb3d5c3b51..bc3743f815 100644 --- a/packages/core/src/modules/credentials/protocol/index.ts +++ b/packages/core/src/modules/credentials/protocol/index.ts @@ -1,4 +1,5 @@ export * from './v2' +export * from './v3' export * from './revocation-notification' import * as CredentialProtocolOptions from './CredentialProtocolOptions' diff --git a/packages/core/src/modules/credentials/protocol/v3/CredentialFormatCoordinator.ts b/packages/core/src/modules/credentials/protocol/v3/CredentialFormatCoordinator.ts new file mode 100644 index 0000000000..c9285cca4c --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/CredentialFormatCoordinator.ts @@ -0,0 +1,536 @@ +import type { AgentContext } from '../../../../agent' +import type { V2Attachment } from '../../../../decorators/attachment' +import type { CredentialFormatPayload, CredentialFormatService, ExtractCredentialFormats } from '../../formats' +import type { CredentialExchangeRecord } from '../../repository/CredentialExchangeRecord' + +import { toV1Attachment, toV2Attachment } from '../../../../didcomm' +import { AriesFrameworkError } from '../../../../error/AriesFrameworkError' +import { DidCommMessageRepository, DidCommMessageRole } from '../../../../storage' + +import { + V3IssueCredentialMessage, + V3OfferCredentialMessage, + V3ProposeCredentialMessage, + V3RequestCredentialMessage, + V3CredentialPreview, +} from './messages' + +export class CredentialFormatCoordinator { + /** + * Create a {@link V3ProposeCredentialMessage}. + * + * @param options + * @returns The created {@link V3ProposeCredentialMessage} + * + */ + public async createProposal( + agentContext: AgentContext, + { + credentialFormats, + formatServices, + credentialRecord, + comment, + }: { + formatServices: CredentialFormatService[] + credentialFormats: CredentialFormatPayload, 'createProposal'> + credentialRecord: CredentialExchangeRecord + comment?: string + } + ): Promise { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + const proposalAttachments: V2Attachment[] = [] + let credentialPreview: V3CredentialPreview | undefined + + for (const formatService of formatServices) { + const { format, attachment, previewAttributes } = await formatService.createProposal(agentContext, { + credentialFormats, + credentialRecord, + }) + + if (previewAttributes) { + credentialPreview = new V3CredentialPreview({ + attributes: previewAttributes, + }) + } + + const v2Attachment = toV2Attachment(attachment) + v2Attachment.format = format.format + proposalAttachments.push(v2Attachment) + } + + credentialRecord.credentialAttributes = credentialPreview?.attributes + + const message = new V3ProposeCredentialMessage({ + id: credentialRecord.threadId, + attachments: proposalAttachments, + comment: comment, + credentialPreview, + }) + + message.thid = credentialRecord.threadId + + await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { + agentMessage: message, + role: DidCommMessageRole.Sender, + associatedRecordId: credentialRecord.id, + }) + + return message + } + + public async processProposal( + agentContext: AgentContext, + { + credentialRecord, + message, + formatServices, + }: { + credentialRecord: CredentialExchangeRecord + message: V3ProposeCredentialMessage + formatServices: CredentialFormatService[] + } + ) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + for (const formatService of formatServices) { + const attachment = this.getAttachmentForService(formatService, message.attachments) + + await formatService.processProposal(agentContext, { + attachment, + credentialRecord, + }) + } + + await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { + agentMessage: message, + role: DidCommMessageRole.Receiver, + associatedRecordId: credentialRecord.id, + }) + } + + public async acceptProposal( + agentContext: AgentContext, + { + credentialRecord, + credentialFormats, + formatServices, + comment, + }: { + credentialRecord: CredentialExchangeRecord + credentialFormats?: CredentialFormatPayload, 'acceptProposal'> + formatServices: CredentialFormatService[] + comment?: string + } + ) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + const offerAttachments: V2Attachment[] = [] + let credentialPreview: V3CredentialPreview | undefined + + const proposalMessage = await didCommMessageRepository.getAgentMessage(agentContext, { + associatedRecordId: credentialRecord.id, + messageClass: V3ProposeCredentialMessage, + }) + + // NOTE: We set the credential attributes from the proposal on the record as we've 'accepted' them + // and can now use them to create the offer in the format services. It may be overwritten later on + // if the user provided other attributes in the credentialFormats array. + credentialRecord.credentialAttributes = proposalMessage.body.credentialPreview?.attributes + + for (const formatService of formatServices) { + const proposalAttachment = this.getAttachmentForService(formatService, proposalMessage.attachments) + + const { attachment, format, previewAttributes } = await formatService.acceptProposal(agentContext, { + credentialRecord, + credentialFormats, + proposalAttachment, + }) + + if (previewAttributes) { + credentialPreview = new V3CredentialPreview({ + attributes: previewAttributes, + }) + } + + const v2Attachment = toV2Attachment(attachment) + v2Attachment.format = format.format + offerAttachments.push(v2Attachment) + } + + credentialRecord.credentialAttributes = credentialPreview?.attributes + + if (!credentialPreview) { + // If no preview attributes were provided, use a blank preview. Not all formats use this object + // but it is required by the protocol + credentialPreview = new V3CredentialPreview({ + attributes: [], + }) + } + + const message = new V3OfferCredentialMessage({ + credentialPreview, + attachments: offerAttachments, + comment, + }) + + message.thid = credentialRecord.threadId + + await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { + agentMessage: message, + associatedRecordId: credentialRecord.id, + role: DidCommMessageRole.Sender, + }) + + return message + } + + /** + * Create a {@link V3OfferCredentialMessage}. + * + * @param options + * @returns The created {@link V3OfferCredentialMessage} + * + */ + public async createOffer( + agentContext: AgentContext, + { + credentialFormats, + formatServices, + credentialRecord, + comment, + }: { + formatServices: CredentialFormatService[] + credentialFormats: CredentialFormatPayload, 'createOffer'> + credentialRecord: CredentialExchangeRecord + comment?: string + } + ): Promise { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + const offerAttachments: V2Attachment[] = [] + let credentialPreview: V3CredentialPreview | undefined + + for (const formatService of formatServices) { + const { format, attachment, previewAttributes } = await formatService.createOffer(agentContext, { + credentialFormats, + credentialRecord, + }) + + if (previewAttributes) { + credentialPreview = new V3CredentialPreview({ + attributes: previewAttributes, + }) + } + + const v2Attachment = toV2Attachment(attachment) + v2Attachment.format = format.format + offerAttachments.push(v2Attachment) + } + + credentialRecord.credentialAttributes = credentialPreview?.attributes + + if (!credentialPreview) { + // If no preview attributes were provided, use a blank preview. Not all formats use this object + // but it is required by the protocol + credentialPreview = new V3CredentialPreview({ + attributes: [], + }) + } + + const message = new V3OfferCredentialMessage({ + comment, + attachments: offerAttachments, + credentialPreview, + }) + + message.thid = credentialRecord.threadId + + await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { + agentMessage: message, + role: DidCommMessageRole.Sender, + associatedRecordId: credentialRecord.id, + }) + + return message + } + + public async processOffer( + agentContext: AgentContext, + { + credentialRecord, + message, + formatServices, + }: { + credentialRecord: CredentialExchangeRecord + message: V3OfferCredentialMessage + formatServices: CredentialFormatService[] + } + ) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + for (const formatService of formatServices) { + const attachment = this.getAttachmentForService(formatService, message.attachments) + + await formatService.processOffer(agentContext, { + attachment, + credentialRecord, + }) + } + + await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { + agentMessage: message, + role: DidCommMessageRole.Receiver, + associatedRecordId: credentialRecord.id, + }) + } + + public async acceptOffer( + agentContext: AgentContext, + { + credentialRecord, + credentialFormats, + formatServices, + comment, + }: { + credentialRecord: CredentialExchangeRecord + credentialFormats?: CredentialFormatPayload, 'acceptOffer'> + formatServices: CredentialFormatService[] + comment?: string + } + ) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + const offerMessage = await didCommMessageRepository.getAgentMessage(agentContext, { + associatedRecordId: credentialRecord.id, + messageClass: V3OfferCredentialMessage, + }) + + const requestAttachments: V2Attachment[] = [] + + for (const formatService of formatServices) { + const offerAttachment = this.getAttachmentForService(formatService, offerMessage.attachments) + + const { attachment, format } = await formatService.acceptOffer(agentContext, { + offerAttachment, + credentialRecord, + credentialFormats, + }) + + const v2Attachment = toV2Attachment(attachment) + v2Attachment.format = format.format + requestAttachments.push(v2Attachment) + } + + credentialRecord.credentialAttributes = offerMessage.body.credentialPreview?.attributes + + const message = new V3RequestCredentialMessage({ + attachments: requestAttachments, + comment, + }) + + message.thid = credentialRecord.threadId + + await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { + agentMessage: message, + associatedRecordId: credentialRecord.id, + role: DidCommMessageRole.Sender, + }) + + return message + } + + /** + * Create a {@link V3RequestCredentialMessage}. + * + * @param options + * @returns The created {@link V3RequestCredentialMessage} + * + */ + public async createRequest( + agentContext: AgentContext, + { + credentialFormats, + formatServices, + credentialRecord, + comment, + }: { + formatServices: CredentialFormatService[] + credentialFormats: CredentialFormatPayload, 'createRequest'> + credentialRecord: CredentialExchangeRecord + comment?: string + } + ): Promise { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + const requestAttachments: V2Attachment[] = [] + + for (const formatService of formatServices) { + const { format, attachment } = await formatService.createRequest(agentContext, { + credentialFormats, + credentialRecord, + }) + + const v2Attachment = toV2Attachment(attachment) + v2Attachment.format = format.format + requestAttachments.push(v2Attachment) + } + + const message = new V3RequestCredentialMessage({ + comment, + attachments: requestAttachments, + }) + + message.thid = credentialRecord.threadId + + await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { + agentMessage: message, + role: DidCommMessageRole.Sender, + associatedRecordId: credentialRecord.id, + }) + + return message + } + + public async processRequest( + agentContext: AgentContext, + { + credentialRecord, + message, + formatServices, + }: { + credentialRecord: CredentialExchangeRecord + message: V3RequestCredentialMessage + formatServices: CredentialFormatService[] + } + ) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + for (const formatService of formatServices) { + const attachment = this.getAttachmentForService(formatService, message.attachments) + + await formatService.processRequest(agentContext, { + attachment, + credentialRecord, + }) + } + + await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { + agentMessage: message, + role: DidCommMessageRole.Receiver, + associatedRecordId: credentialRecord.id, + }) + } + + public async acceptRequest( + agentContext: AgentContext, + { + credentialRecord, + credentialFormats, + formatServices, + comment, + }: { + credentialRecord: CredentialExchangeRecord + credentialFormats?: CredentialFormatPayload, 'acceptRequest'> + formatServices: CredentialFormatService[] + comment?: string + } + ) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + const requestMessage = await didCommMessageRepository.getAgentMessage(agentContext, { + associatedRecordId: credentialRecord.id, + messageClass: V3RequestCredentialMessage, + }) + + const offerMessage = await didCommMessageRepository.findAgentMessage(agentContext, { + associatedRecordId: credentialRecord.id, + messageClass: V3OfferCredentialMessage, + }) + + const credentialAttachments: V2Attachment[] = [] + + for (const formatService of formatServices) { + const requestAttachment = this.getAttachmentForService(formatService, requestMessage.attachments) + + const offerAttachment = offerMessage + ? this.getAttachmentForService(formatService, offerMessage.attachments) + : undefined + + const { attachment, format } = await formatService.acceptRequest(agentContext, { + requestAttachment, + offerAttachment, + credentialRecord, + credentialFormats, + }) + + const v2Attachment = toV2Attachment(attachment) + v2Attachment.format = format.format + credentialAttachments.push(v2Attachment) + } + + const message = new V3IssueCredentialMessage({ + attachments: credentialAttachments, + comment, + }) + + message.thid = credentialRecord.threadId + // TODO: message.setPleaseAck() + + await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { + agentMessage: message, + associatedRecordId: credentialRecord.id, + role: DidCommMessageRole.Sender, + }) + + return message + } + + public async processCredential( + agentContext: AgentContext, + { + credentialRecord, + message, + requestMessage, + formatServices, + }: { + credentialRecord: CredentialExchangeRecord + message: V3IssueCredentialMessage + requestMessage: V3RequestCredentialMessage + formatServices: CredentialFormatService[] + } + ) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + for (const formatService of formatServices) { + const attachment = this.getAttachmentForService(formatService, message.attachments) + const requestAttachment = this.getAttachmentForService(formatService, requestMessage.attachments) + + await formatService.processCredential(agentContext, { + attachment, + requestAttachment, + credentialRecord, + }) + } + + await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { + agentMessage: message, + role: DidCommMessageRole.Receiver, + associatedRecordId: credentialRecord.id, + }) + } + + public getAttachmentForService(credentialFormatService: CredentialFormatService, attachments: V2Attachment[]) { + const attachment = attachments.find( + (attachment) => attachment.format && credentialFormatService.supportsFormat(attachment.format) + ) + + if (!attachment) { + throw new AriesFrameworkError( + `Attachment with format ${credentialFormatService.formatKey} not found in attachments.` + ) + } + + return toV1Attachment(attachment) + } +} diff --git a/packages/core/src/modules/credentials/protocol/v3/V3CredentialProtocol.ts b/packages/core/src/modules/credentials/protocol/v3/V3CredentialProtocol.ts new file mode 100644 index 0000000000..0242e9bfdf --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/V3CredentialProtocol.ts @@ -0,0 +1,1164 @@ +import type { AgentContext } from '../../../../agent' +import type { FeatureRegistry } from '../../../../agent/FeatureRegistry' +import type { MessageHandlerInboundMessage } from '../../../../agent/MessageHandler' +import type { InboundMessageContext } from '../../../../agent/models/InboundMessageContext' +import type { V2Attachment } from '../../../../decorators/attachment' +import type { DidCommV2Message } from '../../../../didcomm' +import type { DependencyManager } from '../../../../plugins' +import type { V2ProblemReportMessage } from '../../../problem-reports' +import type { + CredentialFormat, + CredentialFormatPayload, + CredentialFormatService, + ExtractCredentialFormats, +} from '../../formats' +import type { CredentialProtocol } from '../CredentialProtocol' +import type { + AcceptCredentialOptions, + AcceptCredentialOfferOptions, + AcceptCredentialProposalOptions, + AcceptCredentialRequestOptions, + CreateCredentialOfferOptions, + CreateCredentialProposalOptions, + CreateCredentialRequestOptions, + CredentialProtocolMsgReturnType, + CredentialFormatDataMessagePayload, + CreateCredentialProblemReportOptions, + GetCredentialFormatDataReturn, + NegotiateCredentialOfferOptions, + NegotiateCredentialProposalOptions, +} from '../CredentialProtocolOptions' + +import { Protocol } from '../../../../agent/models/features/Protocol' +import { AriesFrameworkError } from '../../../../error' +import { DidCommMessageRepository } from '../../../../storage' +import { uuid } from '../../../../utils/uuid' +import { CredentialsModuleConfig } from '../../CredentialsModuleConfig' +import { AutoAcceptCredential, CredentialProblemReportReason, CredentialState } from '../../models' +import { CredentialExchangeRecord, CredentialRepository } from '../../repository' +import { composeAutoAccept } from '../../util/composeAutoAccept' +import { arePreviewAttributesEqual } from '../../util/previewAttributes' +import { BaseCredentialProtocol } from '../BaseCredentialProtocol' + +import { CredentialFormatCoordinator } from './CredentialFormatCoordinator' +import { + V3OfferCredentialHandler, + V3CredentialAckHandler, + V3IssueCredentialHandler, + V3ProposeCredentialHandler, + V3RequestCredentialHandler, + V3CredentialProblemReportHandler, +} from './handlers' +import { + V3CredentialAckMessage, + V3CredentialProblemReportMessage, + V3IssueCredentialMessage, + V3OfferCredentialMessage, + V3ProposeCredentialMessage, + V3RequestCredentialMessage, +} from './messages' + +export interface V3CredentialProtocolConfig { + credentialFormats: CredentialFormatServices +} + +export class V3CredentialProtocol + extends BaseCredentialProtocol + implements CredentialProtocol +{ + private credentialFormatCoordinator = new CredentialFormatCoordinator() + private credentialFormats: CFs + + public constructor({ credentialFormats }: V3CredentialProtocolConfig) { + super() + + this.credentialFormats = credentialFormats + } + + /** + * The version of the issue credential protocol this service supports + */ + public readonly version = 'v3' as const + + /** + * Registers the protocol implementation (handlers, feature registry) on the agent. + */ + public register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry) { + // Register message handlers for the Issue Credential V3 Protocol + dependencyManager.registerMessageHandlers([ + new V3ProposeCredentialHandler(this), + new V3OfferCredentialHandler(this), + new V3RequestCredentialHandler(this), + new V3IssueCredentialHandler(this), + new V3CredentialAckHandler(this), + new V3CredentialProblemReportHandler(this), + ]) + + // Register Issue Credential V3 in feature registry, with supported roles + featureRegistry.register( + new Protocol({ + id: 'https://didcomm.org/issue-credential/3.0', + roles: ['holder', 'issuer'], + }) + ) + } + + /** + * Create a {@link V3ProposeCredentialMessage} not bound to an existing credential exchange. + * + * @param proposal The ProposeCredentialOptions object containing the important fields for the credential message + * @returns Object containing proposal message and associated credential record + * + */ + public async createProposal( + agentContext: AgentContext, + { connectionRecord, credentialFormats, comment, autoAcceptCredential }: CreateCredentialProposalOptions + ): Promise> { + agentContext.config.logger.debug('Get the Format Service and Create Proposal Message') + + const credentialRepository = agentContext.dependencyManager.resolve(CredentialRepository) + + const formatServices = this.getFormatServices(credentialFormats) + if (formatServices.length === 0) { + throw new AriesFrameworkError(`Unable to create proposal. No supported formats`) + } + + const credentialRecord = new CredentialExchangeRecord({ + connectionId: connectionRecord.id, + threadId: uuid(), + state: CredentialState.ProposalSent, + autoAcceptCredential, + protocolVersion: 'v3', + }) + + const proposalMessage = await this.credentialFormatCoordinator.createProposal(agentContext, { + credentialFormats, + credentialRecord, + formatServices, + comment, + }) + + agentContext.config.logger.debug('Save record and emit state change event') + await credentialRepository.save(agentContext, credentialRecord) + this.emitStateChangedEvent(agentContext, credentialRecord, null) + + return { credentialRecord, message: proposalMessage } + } + + /** + * Method called by {@link V3ProposeCredentialHandler} on reception of a propose credential message + * We do the necessary processing here to accept the proposal and do the state change, emit event etc. + * @param messageContext the inbound propose credential message + * @returns credential record appropriate for this incoming message (once accepted) + */ + public async processProposal( + messageContext: InboundMessageContext + ): Promise { + const { message: proposalMessage, connection, agentContext } = messageContext + + agentContext.config.logger.debug(`Processing credential proposal with id ${proposalMessage.id}`) + + const credentialRepository = agentContext.dependencyManager.resolve(CredentialRepository) + + let credentialRecord = await this.findByThreadAndConnectionId( + messageContext.agentContext, + proposalMessage.threadId, + connection?.id + ) + + const formatServices = this.getFormatServicesFromAttachments(proposalMessage.attachments) + if (formatServices.length === 0) { + throw new AriesFrameworkError(`Unable to process proposal. No supported formats`) + } + + // credential record already exists + if (credentialRecord) { + // Assert + credentialRecord.assertProtocolVersion('v3') + credentialRecord.assertState(CredentialState.OfferSent) + + await this.credentialFormatCoordinator.processProposal(messageContext.agentContext, { + credentialRecord, + formatServices, + message: proposalMessage, + }) + + await this.updateState(messageContext.agentContext, credentialRecord, CredentialState.ProposalReceived) + + return credentialRecord + } else { + // Assert + + // No credential record exists with thread id + credentialRecord = new CredentialExchangeRecord({ + connectionId: connection?.id, + threadId: proposalMessage.threadId, + state: CredentialState.ProposalReceived, + protocolVersion: 'v3', + }) + + await this.credentialFormatCoordinator.processProposal(messageContext.agentContext, { + credentialRecord, + formatServices, + message: proposalMessage, + }) + + // Save record and emit event + await credentialRepository.save(messageContext.agentContext, credentialRecord) + this.emitStateChangedEvent(messageContext.agentContext, credentialRecord, null) + + return credentialRecord + } + } + + public async acceptProposal( + agentContext: AgentContext, + { credentialRecord, credentialFormats, autoAcceptCredential, comment }: AcceptCredentialProposalOptions + ): Promise> { + // Assert + credentialRecord.assertProtocolVersion('v3') + credentialRecord.assertState(CredentialState.ProposalReceived) + + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + // Use empty credentialFormats if not provided to denote all formats should be accepted + let formatServices = this.getFormatServices(credentialFormats ?? {}) + + // if no format services could be extracted from the credentialFormats + // take all available format services from the proposal message + if (formatServices.length === 0) { + const proposalMessage = await didCommMessageRepository.getAgentMessage(agentContext, { + associatedRecordId: credentialRecord.id, + messageClass: V3ProposeCredentialMessage, + }) + + formatServices = this.getFormatServicesFromAttachments(proposalMessage.attachments) + } + + // If the format services list is still empty, throw an error as we don't support any + // of the formats + if (formatServices.length === 0) { + throw new AriesFrameworkError( + `Unable to accept proposal. No supported formats provided as input or in proposal message` + ) + } + + const offerMessage = await this.credentialFormatCoordinator.acceptProposal(agentContext, { + credentialRecord, + formatServices, + comment, + credentialFormats, + }) + + credentialRecord.autoAcceptCredential = autoAcceptCredential ?? credentialRecord.autoAcceptCredential + await this.updateState(agentContext, credentialRecord, CredentialState.OfferSent) + + return { credentialRecord, message: offerMessage } + } + + /** + * Negotiate a credential proposal as issuer (by sending a credential offer message) to the connection + * associated with the credential record. + * + * @param options configuration for the offer see {@link NegotiateCredentialProposalOptions} + * @returns Credential exchange record associated with the credential offer + * + */ + public async negotiateProposal( + agentContext: AgentContext, + { credentialRecord, credentialFormats, autoAcceptCredential, comment }: NegotiateCredentialProposalOptions + ): Promise> { + // Assert + credentialRecord.assertProtocolVersion('v3') + credentialRecord.assertState(CredentialState.ProposalReceived) + + if (!credentialRecord.connectionId) { + throw new AriesFrameworkError( + `No connectionId found for credential record '${credentialRecord.id}'. Connection-less issuance does not support negotiation.` + ) + } + + const formatServices = this.getFormatServices(credentialFormats) + if (formatServices.length === 0) { + throw new AriesFrameworkError(`Unable to create offer. No supported formats`) + } + + const offerMessage = await this.credentialFormatCoordinator.createOffer(agentContext, { + formatServices, + credentialFormats, + credentialRecord, + comment, + }) + + credentialRecord.autoAcceptCredential = autoAcceptCredential ?? credentialRecord.autoAcceptCredential + await this.updateState(agentContext, credentialRecord, CredentialState.OfferSent) + + return { credentialRecord, message: offerMessage } + } + + /** + * Create a {@link V3OfferCredentialMessage} as beginning of protocol process. If no connectionId is provided, the + * exchange will be created without a connection for usage in oob and connection-less issuance. + * + * @param formatService {@link CredentialFormatService} the format service object containing format-specific logic + * @param options attributes of the original offer + * @returns Object containing offer message and associated credential record + * + */ + public async createOffer( + agentContext: AgentContext, + { credentialFormats, autoAcceptCredential, comment, connectionRecord }: CreateCredentialOfferOptions + ): Promise> { + const credentialRepository = agentContext.dependencyManager.resolve(CredentialRepository) + + const formatServices = this.getFormatServices(credentialFormats) + if (formatServices.length === 0) { + throw new AriesFrameworkError(`Unable to create offer. No supported formats`) + } + + const credentialRecord = new CredentialExchangeRecord({ + connectionId: connectionRecord?.id, + threadId: uuid(), + state: CredentialState.OfferSent, + autoAcceptCredential, + protocolVersion: 'v3', + }) + + const offerMessage = await this.credentialFormatCoordinator.createOffer(agentContext, { + formatServices, + credentialFormats, + credentialRecord, + comment, + }) + + agentContext.config.logger.debug( + `Saving record and emitting state changed for credential exchange record ${credentialRecord.id}` + ) + await credentialRepository.save(agentContext, credentialRecord) + this.emitStateChangedEvent(agentContext, credentialRecord, null) + + return { credentialRecord, message: offerMessage } + } + + /** + * Method called by {@link V3OfferCredentialHandler} on reception of a offer credential message + * We do the necessary processing here to accept the offer and do the state change, emit event etc. + * @param messageContext the inbound offer credential message + * @returns credential record appropriate for this incoming message (once accepted) + */ + public async processOffer( + messageContext: MessageHandlerInboundMessage + ): Promise { + const { message: offerMessage, connection, agentContext } = messageContext + + agentContext.config.logger.debug(`Processing credential offer with id ${offerMessage.id}`) + + const credentialRepository = agentContext.dependencyManager.resolve(CredentialRepository) + + let credentialRecord = await this.findByThreadAndConnectionId( + messageContext.agentContext, + offerMessage.threadId, + connection?.id + ) + + const formatServices = this.getFormatServicesFromAttachments(offerMessage.attachments) + if (formatServices.length === 0) { + throw new AriesFrameworkError(`Unable to process offer. No supported formats`) + } + + // credential record already exists + if (credentialRecord) { + credentialRecord.assertProtocolVersion('v3') + credentialRecord.assertState(CredentialState.ProposalSent) + + await this.credentialFormatCoordinator.processOffer(messageContext.agentContext, { + credentialRecord, + formatServices, + message: offerMessage, + }) + + await this.updateState(messageContext.agentContext, credentialRecord, CredentialState.OfferReceived) + return credentialRecord + } else { + // No credential record exists with thread id + agentContext.config.logger.debug('No credential record found for offer, creating a new one') + credentialRecord = new CredentialExchangeRecord({ + connectionId: connection?.id, + threadId: offerMessage.threadId, + state: CredentialState.OfferReceived, + protocolVersion: 'v3', + }) + + await this.credentialFormatCoordinator.processOffer(messageContext.agentContext, { + credentialRecord, + formatServices, + message: offerMessage, + }) + + // Save in repository + agentContext.config.logger.debug('Saving credential record and emit offer-received event') + await credentialRepository.save(messageContext.agentContext, credentialRecord) + + this.emitStateChangedEvent(messageContext.agentContext, credentialRecord, null) + return credentialRecord + } + } + + public async acceptOffer( + agentContext: AgentContext, + { credentialRecord, autoAcceptCredential, comment, credentialFormats }: AcceptCredentialOfferOptions + ) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + // Assert + credentialRecord.assertProtocolVersion('v3') + credentialRecord.assertState(CredentialState.OfferReceived) + + // Use empty credentialFormats if not provided to denote all formats should be accepted + let formatServices = this.getFormatServices(credentialFormats ?? {}) + + // if no format services could be extracted from the credentialFormats + // take all available format services from the offer message + if (formatServices.length === 0) { + const offerMessage = await didCommMessageRepository.getAgentMessage(agentContext, { + associatedRecordId: credentialRecord.id, + messageClass: V3OfferCredentialMessage, + }) + + formatServices = this.getFormatServicesFromAttachments(offerMessage.attachments) + } + + // If the format services list is still empty, throw an error as we don't support any + // of the formats + if (formatServices.length === 0) { + throw new AriesFrameworkError( + `Unable to accept offer. No supported formats provided as input or in offer message` + ) + } + + const message = await this.credentialFormatCoordinator.acceptOffer(agentContext, { + credentialRecord, + formatServices, + comment, + credentialFormats, + }) + + credentialRecord.autoAcceptCredential = autoAcceptCredential ?? credentialRecord.autoAcceptCredential + await this.updateState(agentContext, credentialRecord, CredentialState.RequestSent) + + return { credentialRecord, message } + } + + /** + * Create a {@link ProposePresentationMessage} as response to a received credential offer. + * To create a proposal not bound to an existing credential exchange, use {@link createProposal}. + * + * @param options configuration to use for the proposal + * @returns Object containing proposal message and associated credential record + * + */ + public async negotiateOffer( + agentContext: AgentContext, + { credentialRecord, credentialFormats, autoAcceptCredential, comment }: NegotiateCredentialOfferOptions + ): Promise> { + // Assert + credentialRecord.assertProtocolVersion('v3') + credentialRecord.assertState(CredentialState.OfferReceived) + + if (!credentialRecord.connectionId) { + throw new AriesFrameworkError( + `No connectionId found for credential record '${credentialRecord.id}'. Connection-less issuance does not support negotiation.` + ) + } + + const formatServices = this.getFormatServices(credentialFormats) + if (formatServices.length === 0) { + throw new AriesFrameworkError(`Unable to create proposal. No supported formats`) + } + + const proposalMessage = await this.credentialFormatCoordinator.createProposal(agentContext, { + formatServices, + credentialFormats, + credentialRecord, + comment, + }) + + credentialRecord.autoAcceptCredential = autoAcceptCredential ?? credentialRecord.autoAcceptCredential + await this.updateState(agentContext, credentialRecord, CredentialState.ProposalSent) + + return { credentialRecord, message: proposalMessage } + } + + /** + * Create a {@link V3RequestCredentialMessage} as beginning of protocol process. + * @returns Object containing offer message and associated credential record + * + */ + public async createRequest( + agentContext: AgentContext, + { credentialFormats, autoAcceptCredential, comment, connectionRecord }: CreateCredentialRequestOptions + ): Promise> { + const credentialRepository = agentContext.dependencyManager.resolve(CredentialRepository) + + const formatServices = this.getFormatServices(credentialFormats) + if (formatServices.length === 0) { + throw new AriesFrameworkError(`Unable to create request. No supported formats`) + } + + const credentialRecord = new CredentialExchangeRecord({ + connectionId: connectionRecord.id, + threadId: uuid(), + state: CredentialState.RequestSent, + autoAcceptCredential, + protocolVersion: 'v3', + }) + + const requestMessage = await this.credentialFormatCoordinator.createRequest(agentContext, { + formatServices, + credentialFormats, + credentialRecord, + comment, + }) + + agentContext.config.logger.debug( + `Saving record and emitting state changed for credential exchange record ${credentialRecord.id}` + ) + await credentialRepository.save(agentContext, credentialRecord) + this.emitStateChangedEvent(agentContext, credentialRecord, null) + + return { credentialRecord, message: requestMessage } + } + + /** + * Process a received {@link RequestCredentialMessage}. This will not accept the credential request + * or send a credential. It will only update the existing credential record with + * the information from the credential request message. Use {@link createCredential} + * after calling this method to create a credential. + *z + * @param messageContext The message context containing a v3 credential request message + * @returns credential record associated with the credential request message + * + */ + public async processRequest( + messageContext: InboundMessageContext + ): Promise { + const { message: requestMessage, connection, agentContext } = messageContext + + const credentialRepository = agentContext.dependencyManager.resolve(CredentialRepository) + + agentContext.config.logger.debug(`Processing credential request with id ${requestMessage.id}`) + + let credentialRecord = await this.findByThreadAndConnectionId( + messageContext.agentContext, + requestMessage.threadId, + connection?.id + ) + + const formatServices = this.getFormatServicesFromAttachments(requestMessage.attachments) + if (formatServices.length === 0) { + throw new AriesFrameworkError(`Unable to process request. No supported formats`) + } + + // credential record already exists + if (credentialRecord) { + // Assert + credentialRecord.assertProtocolVersion('v3') + credentialRecord.assertState(CredentialState.OfferSent) + + await this.credentialFormatCoordinator.processRequest(messageContext.agentContext, { + credentialRecord, + formatServices, + message: requestMessage, + }) + + await this.updateState(messageContext.agentContext, credentialRecord, CredentialState.RequestReceived) + return credentialRecord + } else { + // No credential record exists with thread id + agentContext.config.logger.debug('No credential record found for request, creating a new one') + credentialRecord = new CredentialExchangeRecord({ + connectionId: connection?.id, + threadId: requestMessage.threadId, + state: CredentialState.RequestReceived, + protocolVersion: 'v3', + }) + + await this.credentialFormatCoordinator.processRequest(messageContext.agentContext, { + credentialRecord, + formatServices, + message: requestMessage, + }) + + // Save in repository + agentContext.config.logger.debug('Saving credential record and emit request-received event') + await credentialRepository.save(messageContext.agentContext, credentialRecord) + + this.emitStateChangedEvent(messageContext.agentContext, credentialRecord, null) + return credentialRecord + } + } + + public async acceptRequest( + agentContext: AgentContext, + { credentialRecord, autoAcceptCredential, comment, credentialFormats }: AcceptCredentialRequestOptions + ) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + // Assert + credentialRecord.assertProtocolVersion('v3') + credentialRecord.assertState(CredentialState.RequestReceived) + + // Use empty credentialFormats if not provided to denote all formats should be accepted + let formatServices = this.getFormatServices(credentialFormats ?? {}) + + // if no format services could be extracted from the credentialFormats + // take all available format services from the request message + if (formatServices.length === 0) { + const requestMessage = await didCommMessageRepository.getAgentMessage(agentContext, { + associatedRecordId: credentialRecord.id, + messageClass: V3RequestCredentialMessage, + }) + + formatServices = this.getFormatServicesFromAttachments(requestMessage.attachments) + } + + // If the format services list is still empty, throw an error as we don't support any + // of the formats + if (formatServices.length === 0) { + throw new AriesFrameworkError( + `Unable to accept request. No supported formats provided as input or in request message` + ) + } + const message = await this.credentialFormatCoordinator.acceptRequest(agentContext, { + credentialRecord, + formatServices, + comment, + credentialFormats, + }) + + credentialRecord.autoAcceptCredential = autoAcceptCredential ?? credentialRecord.autoAcceptCredential + await this.updateState(agentContext, credentialRecord, CredentialState.CredentialIssued) + + return { credentialRecord, message } + } + + /** + * Process a received {@link V3IssueCredentialMessage}. This will not accept the credential + * or send a credential acknowledgement. It will only update the existing credential record with + * the information from the issue credential message. Use {@link createAck} + * after calling this method to create a credential acknowledgement. + * + * @param messageContext The message context containing an issue credential message + * + * @returns credential record associated with the issue credential message + * + */ + public async processCredential( + messageContext: InboundMessageContext + ): Promise { + const { message: credentialMessage, connection, agentContext } = messageContext + + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + agentContext.config.logger.debug(`Processing credential with id ${credentialMessage.id}`) + + const credentialRecord = await this.getByThreadAndConnectionId( + messageContext.agentContext, + credentialMessage.threadId, + connection?.id + ) + + const requestMessage = await didCommMessageRepository.getAgentMessage(messageContext.agentContext, { + associatedRecordId: credentialRecord.id, + messageClass: V3RequestCredentialMessage, + }) + + // Assert + credentialRecord.assertProtocolVersion('v3') + credentialRecord.assertState(CredentialState.RequestSent) + + const formatServices = this.getFormatServicesFromAttachments(credentialMessage.attachments) + if (formatServices.length === 0) { + throw new AriesFrameworkError(`Unable to process credential. No supported formats`) + } + + await this.credentialFormatCoordinator.processCredential(messageContext.agentContext, { + credentialRecord, + formatServices, + requestMessage: requestMessage, + message: credentialMessage, + }) + + await this.updateState(messageContext.agentContext, credentialRecord, CredentialState.CredentialReceived) + + return credentialRecord + } + + /** + * Create a {@link V3CredentialAckMessage} as response to a received credential. + * + * @param credentialRecord The credential record for which to create the credential acknowledgement + * @returns Object containing credential acknowledgement message and associated credential record + * + */ + public async acceptCredential( + agentContext: AgentContext, + { credentialRecord }: AcceptCredentialOptions + ): Promise> { + credentialRecord.assertProtocolVersion('v3') + credentialRecord.assertState(CredentialState.CredentialReceived) + + // Create message + const ackMessage = new V3CredentialAckMessage({ + threadId: credentialRecord.threadId, + }) + + await this.updateState(agentContext, credentialRecord, CredentialState.Done) + + return { message: ackMessage, credentialRecord } + } + + /** + * Process a received {@link CredentialAckMessage}. + * + * @param messageContext The message context containing a credential acknowledgement message + * @returns credential record associated with the credential acknowledgement message + * + */ + public async processAck( + messageContext: InboundMessageContext + ): Promise { + const { message: ackMessage, connection, agentContext } = messageContext + + agentContext.config.logger.debug(`Processing credential ack with id ${ackMessage.id}`) + + const credentialRecord = await this.getByThreadAndConnectionId( + messageContext.agentContext, + ackMessage.threadId, + connection?.id + ) + credentialRecord.connectionId = connection?.id + + // Assert + credentialRecord.assertProtocolVersion('v3') + credentialRecord.assertState(CredentialState.CredentialIssued) + + // Update record + await this.updateState(messageContext.agentContext, credentialRecord, CredentialState.Done) + + return credentialRecord + } + + /** + * Create a {@link V3CredentialProblemReportMessage} to be sent. + * + * @param message message to send + * @returns a {@link V3CredentialProblemReportMessage} + * + */ + public async createProblemReport( + agentContext: AgentContext, + { credentialRecord, description }: CreateCredentialProblemReportOptions + ): Promise> { + const message = new V3CredentialProblemReportMessage({ + parentThreadId: credentialRecord.threadId, + body: { + comment: description, + code: CredentialProblemReportReason.IssuanceAbandoned, + }, + }) + + return { credentialRecord, message } + } + + // AUTO ACCEPT METHODS + public async shouldAutoRespondToProposal( + agentContext: AgentContext, + options: { + credentialRecord: CredentialExchangeRecord + proposalMessage: V3ProposeCredentialMessage + } + ): Promise { + const { credentialRecord, proposalMessage } = options + const credentialsModuleConfig = agentContext.dependencyManager.resolve(CredentialsModuleConfig) + + const autoAccept = composeAutoAccept( + credentialRecord.autoAcceptCredential, + credentialsModuleConfig.autoAcceptCredentials + ) + + // Handle always / never cases + if (autoAccept === AutoAcceptCredential.Always) return true + if (autoAccept === AutoAcceptCredential.Never) return false + + const offerMessage = await this.findOfferMessage(agentContext, credentialRecord.id) + if (!offerMessage) return false + + // NOTE: we take the formats from the offerMessage so we always check all services that we last sent + // Otherwise we'll only check the formats from the proposal, which could be different from the formats + // we use. + const formatServices = this.getFormatServicesFromAttachments(offerMessage.attachments) + + for (const formatService of formatServices) { + const offerAttachment = this.credentialFormatCoordinator.getAttachmentForService( + formatService, + offerMessage.attachments + ) + + const proposalAttachment = this.credentialFormatCoordinator.getAttachmentForService( + formatService, + proposalMessage.attachments + ) + + const shouldAutoRespondToFormat = await formatService.shouldAutoRespondToProposal(agentContext, { + credentialRecord, + offerAttachment, + proposalAttachment, + }) + // If any of the formats return false, we should not auto accept + if (!shouldAutoRespondToFormat) return false + } + + // not all formats use the proposal and preview, we only check if they're present on + // either or both of the messages + if (proposalMessage.body.credentialPreview || offerMessage.body.credentialPreview) { + // if one of the message doesn't have a preview, we should not auto accept + if (!proposalMessage.body.credentialPreview || !offerMessage.body.credentialPreview) return false + + // Check if preview values match + return arePreviewAttributesEqual( + proposalMessage.body.credentialPreview.attributes, + offerMessage.body.credentialPreview.attributes + ) + } + + return true + } + + public async shouldAutoRespondToOffer( + agentContext: AgentContext, + options: { + credentialRecord: CredentialExchangeRecord + offerMessage: V3OfferCredentialMessage + } + ): Promise { + const { credentialRecord, offerMessage } = options + const credentialsModuleConfig = agentContext.dependencyManager.resolve(CredentialsModuleConfig) + + const autoAccept = composeAutoAccept( + credentialRecord.autoAcceptCredential, + credentialsModuleConfig.autoAcceptCredentials + ) + // Handle always / never cases + if (autoAccept === AutoAcceptCredential.Always) return true + if (autoAccept === AutoAcceptCredential.Never) return false + + const proposalMessage = await this.findProposalMessage(agentContext, credentialRecord.id) + if (!proposalMessage) return false + + // NOTE: we take the formats from the proposalMessage so we always check all services that we last sent + // Otherwise we'll only check the formats from the offer, which could be different from the formats + // we use. + const formatServices = this.getFormatServicesFromAttachments(proposalMessage.attachments) + + for (const formatService of formatServices) { + const offerAttachment = this.credentialFormatCoordinator.getAttachmentForService( + formatService, + offerMessage.attachments + ) + + const proposalAttachment = this.credentialFormatCoordinator.getAttachmentForService( + formatService, + proposalMessage.attachments + ) + + const shouldAutoRespondToFormat = await formatService.shouldAutoRespondToOffer(agentContext, { + credentialRecord, + offerAttachment, + proposalAttachment, + }) + + // If any of the formats return false, we should not auto accept + + if (!shouldAutoRespondToFormat) return false + } + + // if one of the message doesn't have a preview, we should not auto accept + if (proposalMessage.body.credentialPreview || offerMessage.body.credentialPreview) { + // Check if preview values match + return arePreviewAttributesEqual( + proposalMessage.body.credentialPreview?.attributes ?? [], + offerMessage.body.credentialPreview?.attributes ?? [] + ) + } + return true + } + + public async shouldAutoRespondToRequest( + agentContext: AgentContext, + options: { + credentialRecord: CredentialExchangeRecord + requestMessage: V3RequestCredentialMessage + } + ): Promise { + const { credentialRecord, requestMessage } = options + const credentialsModuleConfig = agentContext.dependencyManager.resolve(CredentialsModuleConfig) + + const autoAccept = composeAutoAccept( + credentialRecord.autoAcceptCredential, + credentialsModuleConfig.autoAcceptCredentials + ) + + // Handle always / never cases + if (autoAccept === AutoAcceptCredential.Always) return true + if (autoAccept === AutoAcceptCredential.Never) return false + + const proposalMessage = await this.findProposalMessage(agentContext, credentialRecord.id) + + const offerMessage = await this.findOfferMessage(agentContext, credentialRecord.id) + if (!offerMessage) return false + + // NOTE: we take the formats from the offerMessage so we always check all services that we last sent + // Otherwise we'll only check the formats from the request, which could be different from the formats + // we use. + const formatServices = this.getFormatServicesFromAttachments(offerMessage.attachments) + + for (const formatService of formatServices) { + const offerAttachment = this.credentialFormatCoordinator.getAttachmentForService( + formatService, + offerMessage.attachments + ) + + const proposalAttachment = proposalMessage + ? this.credentialFormatCoordinator.getAttachmentForService(formatService, proposalMessage.attachments) + : undefined + + const requestAttachment = this.credentialFormatCoordinator.getAttachmentForService( + formatService, + requestMessage.attachments + ) + + const shouldAutoRespondToFormat = await formatService.shouldAutoRespondToRequest(agentContext, { + credentialRecord, + offerAttachment, + requestAttachment, + proposalAttachment, + }) + + // If any of the formats return false, we should not auto accept + if (!shouldAutoRespondToFormat) return false + } + + return true + } + + public async shouldAutoRespondToCredential( + agentContext: AgentContext, + options: { + credentialRecord: CredentialExchangeRecord + credentialMessage: V3IssueCredentialMessage + } + ): Promise { + const { credentialRecord, credentialMessage } = options + const credentialsModuleConfig = agentContext.dependencyManager.resolve(CredentialsModuleConfig) + + const autoAccept = composeAutoAccept( + credentialRecord.autoAcceptCredential, + credentialsModuleConfig.autoAcceptCredentials + ) + + // Handle always / never cases + if (autoAccept === AutoAcceptCredential.Always) return true + if (autoAccept === AutoAcceptCredential.Never) return false + + const proposalMessage = await this.findProposalMessage(agentContext, credentialRecord.id) + const offerMessage = await this.findOfferMessage(agentContext, credentialRecord.id) + + const requestMessage = await this.findRequestMessage(agentContext, credentialRecord.id) + if (!requestMessage) return false + + // NOTE: we take the formats from the requestMessage so we always check all services that we last sent + // Otherwise we'll only check the formats from the credential, which could be different from the formats + // we use. + const formatServices = this.getFormatServicesFromAttachments(requestMessage.attachments) + + for (const formatService of formatServices) { + const offerAttachment = offerMessage + ? this.credentialFormatCoordinator.getAttachmentForService(formatService, offerMessage.attachments) + : undefined + + const proposalAttachment = proposalMessage + ? this.credentialFormatCoordinator.getAttachmentForService(formatService, proposalMessage.attachments) + : undefined + + const requestAttachment = this.credentialFormatCoordinator.getAttachmentForService( + formatService, + requestMessage.attachments + ) + + const credentialAttachment = this.credentialFormatCoordinator.getAttachmentForService( + formatService, + credentialMessage.attachments + ) + + const shouldAutoRespondToFormat = await formatService.shouldAutoRespondToCredential(agentContext, { + credentialRecord, + offerAttachment, + credentialAttachment, + requestAttachment, + proposalAttachment, + }) + + // If any of the formats return false, we should not auto accept + if (!shouldAutoRespondToFormat) return false + } + return true + } + + public async findProposalMessage(agentContext: AgentContext, credentialExchangeId: string) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + return didCommMessageRepository.findAgentMessage(agentContext, { + associatedRecordId: credentialExchangeId, + messageClass: V3ProposeCredentialMessage, + }) + } + + public async findOfferMessage(agentContext: AgentContext, credentialExchangeId: string) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + return await didCommMessageRepository.findAgentMessage(agentContext, { + associatedRecordId: credentialExchangeId, + messageClass: V3OfferCredentialMessage, + }) + } + + public async findRequestMessage(agentContext: AgentContext, credentialExchangeId: string) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + return await didCommMessageRepository.findAgentMessage(agentContext, { + associatedRecordId: credentialExchangeId, + messageClass: V3RequestCredentialMessage, + }) + } + + public async findCredentialMessage(agentContext: AgentContext, credentialExchangeId: string) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + return await didCommMessageRepository.findAgentMessage(agentContext, { + associatedRecordId: credentialExchangeId, + messageClass: V3IssueCredentialMessage, + }) + } + + public async getFormatData( + agentContext: AgentContext, + credentialExchangeId: string + ): Promise>> { + // TODO: we could looking at fetching all record using a single query and then filtering based on the type of the message. + const [proposalMessage, offerMessage, requestMessage, credentialMessage] = await Promise.all([ + this.findProposalMessage(agentContext, credentialExchangeId), + this.findOfferMessage(agentContext, credentialExchangeId), + this.findRequestMessage(agentContext, credentialExchangeId), + this.findCredentialMessage(agentContext, credentialExchangeId), + ]) + + // Create object with the keys and the message formats/attachments. We can then loop over this in a generic + // way so we don't have to add the same operation code four times + const messages = { + proposal: proposalMessage?.attachments, + offer: offerMessage?.attachments, + request: requestMessage?.attachments, + credential: credentialMessage?.attachments, + } as const + + const formatData: GetCredentialFormatDataReturn = { + proposalAttributes: proposalMessage?.body.credentialPreview?.attributes, + offerAttributes: offerMessage?.body.credentialPreview?.attributes, + } + + // We loop through all of the message keys as defined above + for (const [messageKey, attachments] of Object.entries(messages)) { + // Message can be undefined, so we continue if it is not defined + if (!attachments) continue + + // Find all format services associated with the message + const formatServices = this.getFormatServicesFromAttachments(attachments) + const messageFormatData: CredentialFormatDataMessagePayload = {} + + // Loop through all of the format services, for each we will extract the attachment data and assign this to the object + // using the unique format key (e.g. indy) + for (const formatService of formatServices) { + const attachment = this.credentialFormatCoordinator.getAttachmentForService(formatService, attachments) + + messageFormatData[formatService.formatKey] = attachment.getDataAsJson() + } + + formatData[messageKey as Exclude] = + messageFormatData + } + + return formatData + } + + /** + * Get all the format service objects for a given credential format from an incoming message + * @param attachments the attachment objects containing the format name (eg indy) + * @return the credential format service objects in an array - derived from format object keys + */ + private getFormatServicesFromAttachments(attachments: V2Attachment[]): CredentialFormatService[] { + const formatServices = new Set() + + for (const attachment of attachments) { + const service = attachment.format ? this.getFormatServiceForFormat(attachment.format) : undefined + if (service) formatServices.add(service) + } + + return Array.from(formatServices) + } + + /** + * Get all the format service objects for a given credential format + * @param credentialFormats the format object containing various optional parameters + * @return the credential format service objects in an array - derived from format object keys + */ + private getFormatServices( + credentialFormats: CredentialFormatPayload, M> + ): CredentialFormatService[] { + const formats = new Set() + + for (const formatKey of Object.keys(credentialFormats)) { + const formatService = this.getFormatServiceForFormatKey(formatKey) + + if (formatService) formats.add(formatService) + } + + return Array.from(formats) + } + + private getFormatServiceForFormatKey(formatKey: string): CredentialFormatService | null { + const formatService = this.credentialFormats.find((credentialFormat) => credentialFormat.formatKey === formatKey) + + return formatService ?? null + } + + private getFormatServiceForFormat(format: string): CredentialFormatService | null { + const formatService = this.credentialFormats.find((credentialFormat) => credentialFormat.supportsFormat(format)) + + return formatService ?? null + } + + protected getFormatServiceForRecordType(credentialRecordType: string) { + const formatService = this.credentialFormats.find( + (credentialFormat) => credentialFormat.credentialRecordType === credentialRecordType + ) + + if (!formatService) { + throw new AriesFrameworkError( + `No format service found for credential record type ${credentialRecordType} in v3 credential protocol` + ) + } + + return formatService + } +} diff --git a/packages/core/src/modules/credentials/protocol/v3/errors/V3CredentialProblemReportError.ts b/packages/core/src/modules/credentials/protocol/v3/errors/V3CredentialProblemReportError.ts new file mode 100644 index 0000000000..1c593fc158 --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/errors/V3CredentialProblemReportError.ts @@ -0,0 +1,25 @@ +import type { ProblemReportErrorOptions } from '../../../../problem-reports' +import type { CredentialProblemReportReason } from '../../../models/CredentialProblemReportReason' + +import { V2ProblemReportError } from '../../../../problem-reports/errors/V2ProblemReportError' +import { V3CredentialProblemReportMessage } from '../messages/V3CredentialProblemReportMessage' + +export interface V3CredentialProblemReportErrorOptions extends ProblemReportErrorOptions { + problemCode: CredentialProblemReportReason + parentThreadId: string +} + +export class V3CredentialProblemReportError extends V2ProblemReportError { + public problemReport: V3CredentialProblemReportMessage + + public constructor(message: string, { problemCode, parentThreadId }: V3CredentialProblemReportErrorOptions) { + super(message, { problemCode, parentThreadId }) + this.problemReport = new V3CredentialProblemReportMessage({ + parentThreadId, + body: { + code: problemCode, + comment: message, + }, + }) + } +} diff --git a/packages/core/src/modules/credentials/protocol/v3/errors/index.ts b/packages/core/src/modules/credentials/protocol/v3/errors/index.ts new file mode 100644 index 0000000000..498d0e065c --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/errors/index.ts @@ -0,0 +1 @@ +export { V3CredentialProblemReportError, V3CredentialProblemReportErrorOptions } from './V3CredentialProblemReportError' diff --git a/packages/core/src/modules/credentials/protocol/v3/handlers/V3CredentialAckHandler.ts b/packages/core/src/modules/credentials/protocol/v3/handlers/V3CredentialAckHandler.ts new file mode 100644 index 0000000000..758d886e5d --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/handlers/V3CredentialAckHandler.ts @@ -0,0 +1,17 @@ +import type { MessageHandler, MessageHandlerInboundMessage } from '../../../../../agent/MessageHandler' +import type { V3CredentialProtocol } from '../V3CredentialProtocol' + +import { V3CredentialAckMessage } from '../messages/V3CredentialAckMessage' + +export class V3CredentialAckHandler implements MessageHandler { + private credentialProtocol: V3CredentialProtocol + public supportedMessages = [V3CredentialAckMessage] + + public constructor(credentialProtocol: V3CredentialProtocol) { + this.credentialProtocol = credentialProtocol + } + + public async handle(messageContext: MessageHandlerInboundMessage) { + await this.credentialProtocol.processAck(messageContext) + } +} diff --git a/packages/core/src/modules/credentials/protocol/v3/handlers/V3CredentialProblemReportHandler.ts b/packages/core/src/modules/credentials/protocol/v3/handlers/V3CredentialProblemReportHandler.ts new file mode 100644 index 0000000000..06daeacab3 --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/handlers/V3CredentialProblemReportHandler.ts @@ -0,0 +1,17 @@ +import type { MessageHandler, MessageHandlerInboundMessage } from '../../../../../agent/MessageHandler' +import type { V3CredentialProtocol } from '../V3CredentialProtocol' + +import { V3CredentialProblemReportMessage } from '../messages/V3CredentialProblemReportMessage' + +export class V3CredentialProblemReportHandler implements MessageHandler { + private credentialProtocol: V3CredentialProtocol + public supportedMessages = [V3CredentialProblemReportMessage] + + public constructor(credentialProtocol: V3CredentialProtocol) { + this.credentialProtocol = credentialProtocol + } + + public async handle(messageContext: MessageHandlerInboundMessage) { + await this.credentialProtocol.processProblemReport(messageContext) + } +} diff --git a/packages/core/src/modules/credentials/protocol/v3/handlers/V3IssueCredentialHandler.ts b/packages/core/src/modules/credentials/protocol/v3/handlers/V3IssueCredentialHandler.ts new file mode 100644 index 0000000000..82f43cc478 --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/handlers/V3IssueCredentialHandler.ts @@ -0,0 +1,56 @@ +import type { MessageHandler, MessageHandlerInboundMessage } from '../../../../../agent/MessageHandler' +import type { InboundMessageContext } from '../../../../../agent/models/InboundMessageContext' +import type { CredentialExchangeRecord } from '../../../repository/CredentialExchangeRecord' +import type { V3CredentialProtocol } from '../V3CredentialProtocol' + +import { getOutboundMessageContext } from '../../../../../agent/getOutboundMessageContext' +import { AriesFrameworkError } from '../../../../../error' +import { V3IssueCredentialMessage } from '../messages/V3IssueCredentialMessage' + +export class V3IssueCredentialHandler implements MessageHandler { + private credentialProtocol: V3CredentialProtocol + public supportedMessages = [V3IssueCredentialMessage] + + public constructor(credentialProtocol: V3CredentialProtocol) { + this.credentialProtocol = credentialProtocol + } + + public async handle(messageContext: InboundMessageContext) { + const credentialRecord = await this.credentialProtocol.processCredential(messageContext) + + const shouldAutoRespond = await this.credentialProtocol.shouldAutoRespondToCredential(messageContext.agentContext, { + credentialRecord, + credentialMessage: messageContext.message, + }) + + if (shouldAutoRespond) { + return await this.acceptCredential(credentialRecord, messageContext) + } + } + + private async acceptCredential( + credentialRecord: CredentialExchangeRecord, + messageContext: MessageHandlerInboundMessage + ) { + messageContext.agentContext.config.logger.info(`Automatically sending acknowledgement with autoAccept`) + const { message } = await this.credentialProtocol.acceptCredential(messageContext.agentContext, { + credentialRecord, + }) + + const requestMessage = await this.credentialProtocol.findRequestMessage( + messageContext.agentContext, + credentialRecord.id + ) + if (!requestMessage) { + throw new AriesFrameworkError(`No request message found for credential record with id '${credentialRecord.id}'`) + } + + return getOutboundMessageContext(messageContext.agentContext, { + connectionRecord: messageContext.connection, + message, + associatedRecord: credentialRecord, + lastReceivedMessage: messageContext.message, + lastSentMessage: requestMessage, + }) + } +} diff --git a/packages/core/src/modules/credentials/protocol/v3/handlers/V3OfferCredentialHandler.ts b/packages/core/src/modules/credentials/protocol/v3/handlers/V3OfferCredentialHandler.ts new file mode 100644 index 0000000000..f5209a90ac --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/handlers/V3OfferCredentialHandler.ts @@ -0,0 +1,45 @@ +import type { MessageHandler, MessageHandlerInboundMessage } from '../../../../../agent/MessageHandler' +import type { InboundMessageContext } from '../../../../../agent/models/InboundMessageContext' +import type { CredentialExchangeRecord } from '../../../repository/CredentialExchangeRecord' +import type { V3CredentialProtocol } from '../V3CredentialProtocol' + +import { getOutboundMessageContext } from '../../../../../agent/getOutboundMessageContext' +import { V3OfferCredentialMessage } from '../messages/V3OfferCredentialMessage' + +export class V3OfferCredentialHandler implements MessageHandler { + private credentialProtocol: V3CredentialProtocol + + public supportedMessages = [V3OfferCredentialMessage] + + public constructor(credentialProtocol: V3CredentialProtocol) { + this.credentialProtocol = credentialProtocol + } + + public async handle(messageContext: InboundMessageContext) { + const credentialRecord = await this.credentialProtocol.processOffer(messageContext) + + const shouldAutoRespond = await this.credentialProtocol.shouldAutoRespondToOffer(messageContext.agentContext, { + credentialRecord, + offerMessage: messageContext.message, + }) + if (shouldAutoRespond) { + return await this.acceptOffer(credentialRecord, messageContext) + } + } + + private async acceptOffer( + credentialRecord: CredentialExchangeRecord, + messageContext: MessageHandlerInboundMessage + ) { + messageContext.agentContext.config.logger.info(`Automatically sending request with autoAccept`) + + const { message } = await this.credentialProtocol.acceptOffer(messageContext.agentContext, { credentialRecord }) + + return getOutboundMessageContext(messageContext.agentContext, { + connectionRecord: messageContext.connection, + message, + associatedRecord: credentialRecord, + lastReceivedMessage: messageContext.message, + }) + } +} diff --git a/packages/core/src/modules/credentials/protocol/v3/handlers/V3ProposeCredentialHandler.ts b/packages/core/src/modules/credentials/protocol/v3/handlers/V3ProposeCredentialHandler.ts new file mode 100644 index 0000000000..38d0c1c35b --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/handlers/V3ProposeCredentialHandler.ts @@ -0,0 +1,50 @@ +import type { MessageHandler, MessageHandlerInboundMessage } from '../../../../../agent/MessageHandler' +import type { InboundMessageContext } from '../../../../../agent/models/InboundMessageContext' +import type { CredentialExchangeRecord } from '../../../repository/CredentialExchangeRecord' +import type { V3CredentialProtocol } from '../V3CredentialProtocol' + +import { OutboundMessageContext } from '../../../../../agent/models' +import { V3ProposeCredentialMessage } from '../messages/V3ProposeCredentialMessage' + +export class V3ProposeCredentialHandler implements MessageHandler { + private credentialProtocol: V3CredentialProtocol + + public supportedMessages = [V3ProposeCredentialMessage] + + public constructor(credentialProtocol: V3CredentialProtocol) { + this.credentialProtocol = credentialProtocol + } + + public async handle(messageContext: InboundMessageContext) { + const credentialRecord = await this.credentialProtocol.processProposal(messageContext) + + const shouldAutoRespond = await this.credentialProtocol.shouldAutoRespondToProposal(messageContext.agentContext, { + credentialRecord, + proposalMessage: messageContext.message, + }) + + if (shouldAutoRespond) { + return await this.acceptProposal(credentialRecord, messageContext) + } + } + + private async acceptProposal( + credentialRecord: CredentialExchangeRecord, + messageContext: MessageHandlerInboundMessage + ) { + messageContext.agentContext.config.logger.info(`Automatically sending offer with autoAccept`) + + if (!messageContext.connection) { + messageContext.agentContext.config.logger.error('No connection on the messageContext, aborting auto accept') + return + } + + const { message } = await this.credentialProtocol.acceptProposal(messageContext.agentContext, { credentialRecord }) + + return new OutboundMessageContext(message, { + agentContext: messageContext.agentContext, + connection: messageContext.connection, + associatedRecord: credentialRecord, + }) + } +} diff --git a/packages/core/src/modules/credentials/protocol/v3/handlers/V3RequestCredentialHandler.ts b/packages/core/src/modules/credentials/protocol/v3/handlers/V3RequestCredentialHandler.ts new file mode 100644 index 0000000000..acec50762b --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/handlers/V3RequestCredentialHandler.ts @@ -0,0 +1,56 @@ +import type { MessageHandler } from '../../../../../agent/MessageHandler' +import type { InboundMessageContext } from '../../../../../agent/models/InboundMessageContext' +import type { CredentialExchangeRecord } from '../../../repository' +import type { V3CredentialProtocol } from '../V3CredentialProtocol' + +import { getOutboundMessageContext } from '../../../../../agent/getOutboundMessageContext' +import { AriesFrameworkError } from '../../../../../error' +import { V3RequestCredentialMessage } from '../messages/V3RequestCredentialMessage' + +export class V3RequestCredentialHandler implements MessageHandler { + private credentialProtocol: V3CredentialProtocol + + public supportedMessages = [V3RequestCredentialMessage] + + public constructor(credentialProtocol: V3CredentialProtocol) { + this.credentialProtocol = credentialProtocol + } + + public async handle(messageContext: InboundMessageContext) { + const credentialRecord = await this.credentialProtocol.processRequest(messageContext) + + const shouldAutoRespond = await this.credentialProtocol.shouldAutoRespondToRequest(messageContext.agentContext, { + credentialRecord, + requestMessage: messageContext.message, + }) + + if (shouldAutoRespond) { + return await this.acceptRequest(credentialRecord, messageContext) + } + } + + private async acceptRequest( + credentialRecord: CredentialExchangeRecord, + messageContext: InboundMessageContext + ) { + messageContext.agentContext.config.logger.info(`Automatically sending credential with autoAccept`) + + const offerMessage = await this.credentialProtocol.findOfferMessage( + messageContext.agentContext, + credentialRecord.id + ) + if (!offerMessage) { + throw new AriesFrameworkError(`Could not find offer message for credential record with id ${credentialRecord.id}`) + } + + const { message } = await this.credentialProtocol.acceptRequest(messageContext.agentContext, { + credentialRecord, + }) + + return getOutboundMessageContext(messageContext.agentContext, { + connectionRecord: messageContext.connection, + message, + associatedRecord: credentialRecord, + }) + } +} diff --git a/packages/core/src/modules/credentials/protocol/v3/handlers/index.ts b/packages/core/src/modules/credentials/protocol/v3/handlers/index.ts new file mode 100644 index 0000000000..7b55ec4a2a --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/handlers/index.ts @@ -0,0 +1,6 @@ +export * from './V3CredentialAckHandler' +export * from './V3IssueCredentialHandler' +export * from './V3OfferCredentialHandler' +export * from './V3ProposeCredentialHandler' +export * from './V3RequestCredentialHandler' +export * from './V3CredentialProblemReportHandler' diff --git a/packages/core/src/modules/credentials/protocol/v3/index.ts b/packages/core/src/modules/credentials/protocol/v3/index.ts new file mode 100644 index 0000000000..b48358825d --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/index.ts @@ -0,0 +1,3 @@ +export * from './V3CredentialProtocol' +export * from './messages' +export * from './errors' diff --git a/packages/core/src/modules/credentials/protocol/v3/messages/V3CredentialAckMessage.ts b/packages/core/src/modules/credentials/protocol/v3/messages/V3CredentialAckMessage.ts new file mode 100644 index 0000000000..755455c53c --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/messages/V3CredentialAckMessage.ts @@ -0,0 +1,25 @@ +import { DidCommV2Message } from '../../../../../didcomm' +import { IsValidMessageType, parseMessageType } from '../../../../../utils/messageType' + +export type V3CredentialAckMessageOptions = { + id?: string + threadId: string +} + +export class V3CredentialAckMessage extends DidCommV2Message { + /** + * Create new V3CredentialAckMessage instance. + * @param options + */ + public constructor(options: V3CredentialAckMessageOptions) { + super() + if (options) { + this.id = options.id ?? this.generateId() + this.thid = options.threadId + } + } + + @IsValidMessageType(V3CredentialAckMessage.type) + public readonly type = V3CredentialAckMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/issue-credential/3.0/ack') +} diff --git a/packages/core/src/modules/credentials/protocol/v3/messages/V3CredentialPreview.ts b/packages/core/src/modules/credentials/protocol/v3/messages/V3CredentialPreview.ts new file mode 100644 index 0000000000..3c4aaf1725 --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/messages/V3CredentialPreview.ts @@ -0,0 +1,64 @@ +import type { CredentialPreviewOptions } from '../../../models/CredentialPreviewAttribute' + +import { Expose, Transform, Type } from 'class-transformer' +import { IsInstance, ValidateNested } from 'class-validator' + +import { JsonTransformer } from '../../../../../utils/JsonTransformer' +import { IsValidMessageType, parseMessageType, replaceLegacyDidSovPrefix } from '../../../../../utils/messageType' +import { CredentialPreviewAttribute } from '../../../models/CredentialPreviewAttribute' + +/** + * Credential preview inner message class. + * + * This is not a message but an inner object for other messages in this protocol. It is used construct a preview of the data for the credential. + * + * @see https://github.com/hyperledger/aries-rfcs/tree/main/features/0453-issue-credential-v2#preview-credential + */ +export class V3CredentialPreview { + public constructor(options: CredentialPreviewOptions) { + if (options) { + this.attributes = options.attributes.map((a) => new CredentialPreviewAttribute(a)) + } + } + + @Expose({ name: 'type' }) + @IsValidMessageType(V3CredentialPreview.type) + @Transform(({ value }) => replaceLegacyDidSovPrefix(value), { + toClassOnly: true, + }) + public readonly type = V3CredentialPreview.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/issue-credential/3.0/credential-preview') + + @Type(() => CredentialPreviewAttribute) + @ValidateNested({ each: true }) + @IsInstance(CredentialPreviewAttribute, { each: true }) + public attributes!: CredentialPreviewAttribute[] + + public toJSON(): Record { + return JsonTransformer.toJSON(this) + } + + /** + * Create a credential preview from a record with name and value entries. + * + * @example + * const preview = CredentialPreview.fromRecord({ + * name: "Bob", + * age: "20" + * }) + */ + public static fromRecord(record: Record) { + const attributes = Object.entries(record).map( + ([name, value]) => + new CredentialPreviewAttribute({ + name, + mimeType: 'text/plain', + value, + }) + ) + + return new V3CredentialPreview({ + attributes, + }) + } +} diff --git a/packages/core/src/modules/credentials/protocol/v3/messages/V3CredentialProblemReportMessage.ts b/packages/core/src/modules/credentials/protocol/v3/messages/V3CredentialProblemReportMessage.ts new file mode 100644 index 0000000000..6f2dd834d4 --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/messages/V3CredentialProblemReportMessage.ts @@ -0,0 +1,23 @@ +import type { V2ProblemReportMessageOptions } from '../../../../problem-reports/versions/v2/messages' + +import { IsValidMessageType, parseMessageType } from '../../../../../utils/messageType' +import { V2ProblemReportMessage } from '../../../../problem-reports/versions/v2' + +export type V3CredentialProblemReportMessageOptions = V2ProblemReportMessageOptions + +/** + * @see https://github.com/hyperledger/aries-rfcs/blob/main/features/0035-report-problem/README.md + */ +export class V3CredentialProblemReportMessage extends V2ProblemReportMessage { + /** + * Create new CredentialProblemReportMessage instance. + * @param options + */ + public constructor(options: V3CredentialProblemReportMessageOptions) { + super(options) + } + + @IsValidMessageType(V3CredentialProblemReportMessage.type) + public readonly type = V3CredentialProblemReportMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/issue-credential/3.0/problem-report') +} diff --git a/packages/core/src/modules/credentials/protocol/v3/messages/V3IssueCredentialMessage.ts b/packages/core/src/modules/credentials/protocol/v3/messages/V3IssueCredentialMessage.ts new file mode 100644 index 0000000000..74787aa63d --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/messages/V3IssueCredentialMessage.ts @@ -0,0 +1,66 @@ +import { Expose, Type } from 'class-transformer' +import { IsArray, IsInstance, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator' + +import { V2Attachment } from '../../../../../decorators/attachment' +import { DidCommV2Message } from '../../../../../didcomm' +import { IsValidMessageType, parseMessageType } from '../../../../../utils/messageType' + +export interface V3IssueCredentialMessageOptions { + id?: string + comment?: string + goalCode?: string + replacementId?: string + attachments: V2Attachment[] +} + +class V3IssueCredentialMessageBody { + public constructor(options: { goalCode?: string; comment?: string; replacementId?: string }) { + if (options) { + this.comment = options.comment + this.goalCode = options.goalCode + this.replacementId = options.replacementId + } + } + + @IsString() + @IsOptional() + public comment?: string + + @Expose({ name: 'goal_code' }) + @IsString() + @IsOptional() + public goalCode?: string + + @Expose({ name: 'replacement_id' }) + @IsString() + @IsOptional() + public replacementId?: string +} + +export class V3IssueCredentialMessage extends DidCommV2Message { + public constructor(options: V3IssueCredentialMessageOptions) { + super() + + if (options) { + this.id = options.id ?? this.generateId() + this.body = new V3IssueCredentialMessageBody(options) + this.attachments = options.attachments + } + } + @IsValidMessageType(V3IssueCredentialMessage.type) + public readonly type = V3IssueCredentialMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/issue-credential/3.0/issue-credential') + + @IsObject() + @ValidateNested() + @Type(() => V3IssueCredentialMessageBody) + public body!: V3IssueCredentialMessageBody + + @Type(() => V2Attachment) + @IsArray() + @ValidateNested({ + each: true, + }) + @IsInstance(V2Attachment, { each: true }) + public attachments!: V2Attachment[] +} diff --git a/packages/core/src/modules/credentials/protocol/v3/messages/V3OfferCredentialMessage.ts b/packages/core/src/modules/credentials/protocol/v3/messages/V3OfferCredentialMessage.ts new file mode 100644 index 0000000000..61de226033 --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/messages/V3OfferCredentialMessage.ts @@ -0,0 +1,80 @@ +import { Expose, Type } from 'class-transformer' +import { IsArray, IsInstance, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator' + +import { V2Attachment } from '../../../../../decorators/attachment' +import { DidCommV2Message } from '../../../../../didcomm' +import { IsValidMessageType, parseMessageType } from '../../../../../utils/messageType' + +import { V3CredentialPreview } from './V3CredentialPreview' + +export interface V3OfferCredentialMessageOptions { + id?: string + attachments: V2Attachment[] + credentialPreview: V3CredentialPreview + replacementId?: string + comment?: string +} + +class V3OfferCredentialMessageBody { + public constructor(options: { + goalCode?: string + comment?: string + credentialPreview?: V3CredentialPreview + replacementId?: string + }) { + if (options) { + this.comment = options.comment + this.credentialPreview = options.credentialPreview + this.goalCode = options.goalCode + this.replacementId = options.replacementId + } + } + + @IsString() + @IsOptional() + public comment?: string + + @Expose({ name: 'goal_code' }) + @IsString() + @IsOptional() + public goalCode?: string + + @Expose({ name: 'credential_preview' }) + @Type(() => V3CredentialPreview) + @ValidateNested() + @IsInstance(V3CredentialPreview) + public credentialPreview?: V3CredentialPreview + + @Expose({ name: 'replacement_id' }) + @IsString() + @IsOptional() + public replacementId?: string +} + +export class V3OfferCredentialMessage extends DidCommV2Message { + public constructor(options: V3OfferCredentialMessageOptions) { + super() + if (options) { + this.id = options.id ?? this.generateId() + this.body = new V3OfferCredentialMessageBody(options) + this.attachments = options.attachments + } + } + + @IsValidMessageType(V3OfferCredentialMessage.type) + public readonly type = V3OfferCredentialMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/issue-credential/3.0/offer-credential') + + @IsObject() + @ValidateNested() + @Type(() => V3OfferCredentialMessageBody) + public body!: V3OfferCredentialMessageBody + + @Type(() => V2Attachment) + @IsArray() + @ValidateNested({ + each: true, + }) + @IsInstance(V2Attachment, { each: true }) + public attachments!: V2Attachment[] +} diff --git a/packages/core/src/modules/credentials/protocol/v3/messages/V3ProposeCredentialMessage.ts b/packages/core/src/modules/credentials/protocol/v3/messages/V3ProposeCredentialMessage.ts new file mode 100644 index 0000000000..93baacc1fc --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/messages/V3ProposeCredentialMessage.ts @@ -0,0 +1,69 @@ +import { Expose, Type } from 'class-transformer' +import { IsArray, IsInstance, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator' + +import { V2Attachment } from '../../../../../decorators/attachment' +import { DidCommV2Message } from '../../../../../didcomm' +import { IsValidMessageType, parseMessageType } from '../../../../../utils/messageType' + +import { V3CredentialPreview } from './V3CredentialPreview' + +export interface V3ProposeCredentialMessageOptions { + id?: string + comment?: string + credentialPreview?: V3CredentialPreview + attachments: V2Attachment[] +} + +class V3ProposeCredentialMessageBody { + public constructor(options: { goalCode?: string; comment?: string; credentialPreview?: V3CredentialPreview }) { + if (options) { + this.comment = options.comment + this.credentialPreview = options.credentialPreview + this.goalCode = options.goalCode + } + } + + @IsString() + @IsOptional() + public comment?: string + + @Expose({ name: 'goal_code' }) + @IsString() + @IsOptional() + public goalCode?: string + + @Expose({ name: 'credential_preview' }) + @Type(() => V3CredentialPreview) + @ValidateNested() + @IsOptional() + @IsInstance(V3CredentialPreview) + public credentialPreview?: V3CredentialPreview +} + +export class V3ProposeCredentialMessage extends DidCommV2Message { + public constructor(options: V3ProposeCredentialMessageOptions) { + super() + if (options) { + this.id = options.id ?? this.generateId() + this.body = new V3ProposeCredentialMessageBody(options) + this.attachments = options.attachments + } + } + + @IsValidMessageType(V3ProposeCredentialMessage.type) + public readonly type = V3ProposeCredentialMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/issue-credential/3.0/propose-credential') + + @IsObject() + @ValidateNested() + @Type(() => V3ProposeCredentialMessageBody) + public body!: V3ProposeCredentialMessageBody + + @Type(() => V2Attachment) + @IsArray() + @ValidateNested({ + each: true, + }) + @IsInstance(V2Attachment, { each: true }) + public attachments!: V2Attachment[] +} diff --git a/packages/core/src/modules/credentials/protocol/v3/messages/V3RequestCredentialMessage.ts b/packages/core/src/modules/credentials/protocol/v3/messages/V3RequestCredentialMessage.ts new file mode 100644 index 0000000000..4aba0c7482 --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/messages/V3RequestCredentialMessage.ts @@ -0,0 +1,59 @@ +import { Expose, Type } from 'class-transformer' +import { IsArray, IsInstance, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator' + +import { V2Attachment } from '../../../../../decorators/attachment' +import { DidCommV2Message } from '../../../../../didcomm' +import { IsValidMessageType, parseMessageType } from '../../../../../utils/messageType' + +export interface V3RequestCredentialMessageOptions { + id?: string + attachments: V2Attachment[] + comment?: string + goalCode?: string +} + +class V3RequestCredentialMessageBody { + public constructor(options: { goalCode?: string; comment?: string }) { + if (options) { + this.comment = options.comment + this.goalCode = options.goalCode + } + } + + @IsString() + @IsOptional() + public comment?: string + + @Expose({ name: 'goal_code' }) + @IsString() + @IsOptional() + public goalCode?: string +} + +export class V3RequestCredentialMessage extends DidCommV2Message { + public constructor(options: V3RequestCredentialMessageOptions) { + super() + if (options) { + this.id = options.id ?? this.generateId() + this.body = new V3RequestCredentialMessageBody(options) + this.attachments = options.attachments + } + } + + @IsValidMessageType(V3RequestCredentialMessage.type) + public readonly type = V3RequestCredentialMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/issue-credential/3.0/request-credential') + + @IsObject() + @ValidateNested() + @Type(() => V3RequestCredentialMessageBody) + public body!: V3RequestCredentialMessageBody + + @Type(() => V2Attachment) + @IsArray() + @ValidateNested({ + each: true, + }) + @IsInstance(V2Attachment, { each: true }) + public attachments!: V2Attachment[] +} diff --git a/packages/core/src/modules/credentials/protocol/v3/messages/index.ts b/packages/core/src/modules/credentials/protocol/v3/messages/index.ts new file mode 100644 index 0000000000..a9fa9c6fcc --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/v3/messages/index.ts @@ -0,0 +1,7 @@ +export * from './V3CredentialAckMessage' +export * from './V3CredentialProblemReportMessage' +export * from './V3IssueCredentialMessage' +export * from './V3OfferCredentialMessage' +export * from './V3ProposeCredentialMessage' +export * from './V3RequestCredentialMessage' +export * from './V3CredentialPreview' diff --git a/packages/core/src/modules/problem-reports/errors/V2ProblemReportError.ts b/packages/core/src/modules/problem-reports/errors/V2ProblemReportError.ts new file mode 100644 index 0000000000..cc107376b7 --- /dev/null +++ b/packages/core/src/modules/problem-reports/errors/V2ProblemReportError.ts @@ -0,0 +1,22 @@ +import { AriesFrameworkError } from '../../../error/AriesFrameworkError' +import { V2ProblemReportMessage } from '../versions/v2/messages' + +export interface V2ProblemReportErrorOptions { + problemCode: string + parentThreadId: string +} + +export class V2ProblemReportError extends AriesFrameworkError { + public problemReport: V2ProblemReportMessage + + public constructor(message: string, { problemCode, parentThreadId }: V2ProblemReportErrorOptions) { + super(message) + this.problemReport = new V2ProblemReportMessage({ + parentThreadId, + body: { + comment: message, + code: problemCode, + }, + }) + } +} diff --git a/packages/core/src/modules/problem-reports/versions/v2/helpers.ts b/packages/core/src/modules/problem-reports/versions/v2/helpers.ts index 1440efd8fb..72d0a3ee6a 100644 --- a/packages/core/src/modules/problem-reports/versions/v2/helpers.ts +++ b/packages/core/src/modules/problem-reports/versions/v2/helpers.ts @@ -2,7 +2,7 @@ import type { PlaintextDidCommV2Message } from '../../../../didcomm' import { ProblemReportReason } from '../../models/ProblemReportReason' -import { ProblemReportMessage } from './messages' +import { V2ProblemReportMessage } from './messages' /** * Build the v2 problem report message to the recipient. @@ -12,11 +12,11 @@ import { ProblemReportMessage } from './messages' export const buildProblemReportV2Message = ( plaintextMessage: PlaintextDidCommV2Message, errorMessage: string -): ProblemReportMessage | undefined => { +): V2ProblemReportMessage | undefined => { // Cannot send problem report for message with unknown sender or recipient if (!plaintextMessage.from || !plaintextMessage.to?.length) return - return new ProblemReportMessage({ + return new V2ProblemReportMessage({ parentThreadId: plaintextMessage.id, from: plaintextMessage.to.length ? plaintextMessage.to[0] : undefined, to: plaintextMessage.from, diff --git a/packages/core/src/modules/problem-reports/versions/v2/index.ts b/packages/core/src/modules/problem-reports/versions/v2/index.ts index f9d434a2f5..3c4007f660 100644 --- a/packages/core/src/modules/problem-reports/versions/v2/index.ts +++ b/packages/core/src/modules/problem-reports/versions/v2/index.ts @@ -1,2 +1,2 @@ -export { ProblemReportMessage as V2ProblemReportMessage } from './messages' +export { V2ProblemReportMessage as V2ProblemReportMessage } from './messages' export { buildProblemReportV2Message } from './helpers' diff --git a/packages/core/src/modules/problem-reports/versions/v2/messages/ProblemReportMessage.ts b/packages/core/src/modules/problem-reports/versions/v2/messages/ProblemReportMessage.ts index 299066f047..3469ff4b3f 100644 --- a/packages/core/src/modules/problem-reports/versions/v2/messages/ProblemReportMessage.ts +++ b/packages/core/src/modules/problem-reports/versions/v2/messages/ProblemReportMessage.ts @@ -27,7 +27,7 @@ export class ProblemReportBody { public args?: string[] } -export class ProblemReportMessage extends DidCommV2Message { +export class V2ProblemReportMessage extends DidCommV2Message { public constructor(options?: V2ProblemReportMessageOptions) { super(options) if (options) { @@ -36,8 +36,8 @@ export class ProblemReportMessage extends DidCommV2Message { } } - @IsValidMessageType(ProblemReportMessage.type) - public readonly type = ProblemReportMessage.type.messageTypeUri + @IsValidMessageType(V2ProblemReportMessage.type) + public readonly type = V2ProblemReportMessage.type.messageTypeUri public static readonly type = parseMessageType('https://didcomm.org/report-problem/2.0/problem-report') @Expose({ name: 'body' }) diff --git a/packages/core/src/modules/proofs/ProofsApi.ts b/packages/core/src/modules/proofs/ProofsApi.ts index 69ff9af772..02bfd0d1e5 100644 --- a/packages/core/src/modules/proofs/ProofsApi.ts +++ b/packages/core/src/modules/proofs/ProofsApi.ts @@ -30,6 +30,7 @@ import { injectable } from 'tsyringe' import { MessageSender } from '../../agent/MessageSender' import { AgentContext } from '../../agent/context/AgentContext' import { getOutboundMessageContext } from '../../agent/getOutboundMessageContext' +import { isDidCommV1Message } from '../../didcomm' import { AriesFrameworkError } from '../../error' import { ConnectionService } from '../connections/services/ConnectionService' @@ -385,7 +386,7 @@ export class ProofsApi implements ProofsApi { }> { const protocol = this.getProtocol(options.protocolVersion) - return await protocol.createRequest(this.agentContext, { + const { message, proofRecord } = await protocol.createRequest(this.agentContext, { proofFormats: options.proofFormats, autoAcceptProof: options.autoAcceptProof, comment: options.comment, @@ -393,6 +394,12 @@ export class ProofsApi implements ProofsApi { goalCode: options.goalCode, willConfirm: options.willConfirm, }) + + if (!isDidCommV1Message(message)) { + throw new AriesFrameworkError('out-of-band credential offer is only supported for DIDComm V1') + } + + return { message, proofRecord } } /** diff --git a/packages/core/src/modules/proofs/ProofsModule.ts b/packages/core/src/modules/proofs/ProofsModule.ts index b154e6bef3..84e9343338 100644 --- a/packages/core/src/modules/proofs/ProofsModule.ts +++ b/packages/core/src/modules/proofs/ProofsModule.ts @@ -7,13 +7,13 @@ import type { Constructor } from '../../utils/mixins' import { ProofsApi } from './ProofsApi' import { ProofsModuleConfig } from './ProofsModuleConfig' -import { V2ProofProtocol } from './protocol' +import { V2ProofProtocol, V3ProofProtocol } from './protocol' import { ProofRepository } from './repository' /** * Default proofProtocols that will be registered if the `proofProtocols` property is not configured. */ -export type DefaultProofProtocols = [V2ProofProtocol<[]>] +export type DefaultProofProtocols = [V2ProofProtocol<[]>, V3ProofProtocol<[]>] // ProofsModuleOptions makes the proofProtocols property optional from the config, as it will set it when not provided. export type ProofsModuleOptions = Optional< @@ -31,7 +31,10 @@ export class ProofsModule } diff --git a/packages/core/src/modules/proofs/ProofsModuleConfig.ts b/packages/core/src/modules/proofs/ProofsModuleConfig.ts index e87966ef27..8a412b8935 100644 --- a/packages/core/src/modules/proofs/ProofsModuleConfig.ts +++ b/packages/core/src/modules/proofs/ProofsModuleConfig.ts @@ -18,11 +18,11 @@ export interface ProofsModuleConfigOptions { test('registers V2ProofProtocol if no proofProtocols are configured', () => { const proofsModule = new ProofsModule() - expect(proofsModule.config.proofProtocols).toEqual([expect.any(V2ProofProtocol)]) + expect(proofsModule.config.proofProtocols).toEqual([expect.any(V2ProofProtocol), expect.any(V3ProofProtocol)]) }) test('calls register on the provided ProofProtocols', () => { diff --git a/packages/core/src/modules/proofs/protocol/BaseProofProtocol.ts b/packages/core/src/modules/proofs/protocol/BaseProofProtocol.ts index fba639c477..98e7643afb 100644 --- a/packages/core/src/modules/proofs/protocol/BaseProofProtocol.ts +++ b/packages/core/src/modules/proofs/protocol/BaseProofProtocol.ts @@ -16,18 +16,19 @@ import type { SelectCredentialsForRequestOptions, SelectCredentialsForRequestReturn, } from './ProofProtocolOptions' +import type { AgentBaseMessage } from '../../../agent/AgentBaseMessage' import type { FeatureRegistry } from '../../../agent/FeatureRegistry' import type { AgentContext } from '../../../agent/context/AgentContext' import type { InboundMessageContext } from '../../../agent/models/InboundMessageContext' -import type { DidCommV1Message } from '../../../didcomm' import type { DependencyManager } from '../../../plugins' import type { Query } from '../../../storage/StorageService' -import type { ProblemReportMessage } from '../../problem-reports' +import type { ProblemReportMessage, V2ProblemReportMessage } from '../../problem-reports' import type { ProofStateChangedEvent } from '../ProofEvents' import type { ExtractProofFormats, ProofFormatService } from '../formats' import type { ProofExchangeRecord } from '../repository' import { EventEmitter } from '../../../agent/EventEmitter' +import { DidCommV1Message } from '../../../didcomm' import { DidCommMessageRepository } from '../../../storage' import { ProofEventTypes } from '../ProofEvents' import { ProofState } from '../models/ProofState' @@ -44,31 +45,31 @@ export abstract class BaseProofProtocol - ): Promise> - public abstract processProposal(messageContext: InboundMessageContext): Promise + ): Promise> + public abstract processProposal(messageContext: InboundMessageContext): Promise public abstract acceptProposal( agentContext: AgentContext, options: AcceptProofProposalOptions - ): Promise> + ): Promise> public abstract negotiateProposal( agentContext: AgentContext, options: NegotiateProofProposalOptions - ): Promise> + ): Promise> // methods for request public abstract createRequest( agentContext: AgentContext, options: CreateProofRequestOptions - ): Promise> - public abstract processRequest(messageContext: InboundMessageContext): Promise + ): Promise> + public abstract processRequest(messageContext: InboundMessageContext): Promise public abstract acceptRequest( agentContext: AgentContext, options: AcceptProofRequestOptions - ): Promise> + ): Promise> public abstract negotiateRequest( agentContext: AgentContext, options: NegotiateProofRequestOptions - ): Promise> + ): Promise> // retrieving credentials for request public abstract getCredentialsForRequest( @@ -82,40 +83,40 @@ export abstract class BaseProofProtocol + messageContext: InboundMessageContext ): Promise public abstract acceptPresentation( agentContext: AgentContext, options: AcceptPresentationOptions - ): Promise> + ): Promise> // methods for ack - public abstract processAck(messageContext: InboundMessageContext): Promise + public abstract processAck(messageContext: InboundMessageContext): Promise // method for problem report public abstract createProblemReport( agentContext: AgentContext, options: CreateProofProblemReportOptions - ): Promise> + ): Promise> public abstract findProposalMessage( agentContext: AgentContext, proofExchangeId: string - ): Promise + ): Promise public abstract findRequestMessage( agentContext: AgentContext, proofExchangeId: string - ): Promise + ): Promise public abstract findPresentationMessage( agentContext: AgentContext, proofExchangeId: string - ): Promise + ): Promise public abstract getFormatData( agentContext: AgentContext, proofExchangeId: string ): Promise>> public async processProblemReport( - messageContext: InboundMessageContext + messageContext: InboundMessageContext ): Promise { const { message: proofProblemReportMessage, agentContext, connection } = messageContext @@ -128,7 +129,10 @@ export abstract class BaseProofProtocol - ): Promise> - processProposal(messageContext: InboundMessageContext): Promise + ): Promise> + processProposal(messageContext: InboundMessageContext): Promise acceptProposal( agentContext: AgentContext, options: AcceptProofProposalOptions - ): Promise> + ): Promise> negotiateProposal( agentContext: AgentContext, options: NegotiateProofProposalOptions - ): Promise> + ): Promise> // methods for request createRequest( agentContext: AgentContext, options: CreateProofRequestOptions - ): Promise> - processRequest(messageContext: InboundMessageContext): Promise + ): Promise> + processRequest(messageContext: InboundMessageContext): Promise acceptRequest( agentContext: AgentContext, options: AcceptProofRequestOptions - ): Promise> + ): Promise> negotiateRequest( agentContext: AgentContext, options: NegotiateProofRequestOptions - ): Promise> + ): Promise> // retrieving credentials for request getCredentialsForRequest( @@ -70,25 +70,27 @@ export interface ProofProtocol> // methods for presentation - processPresentation(messageContext: InboundMessageContext): Promise + processPresentation(messageContext: InboundMessageContext): Promise acceptPresentation( agentContext: AgentContext, options: AcceptPresentationOptions - ): Promise> + ): Promise> // methods for ack - processAck(messageContext: InboundMessageContext): Promise + processAck(messageContext: InboundMessageContext): Promise // method for problem report createProblemReport( agentContext: AgentContext, options: CreateProofProblemReportOptions - ): Promise> - processProblemReport(messageContext: InboundMessageContext): Promise + ): Promise> + processProblemReport( + messageContext: InboundMessageContext + ): Promise - findProposalMessage(agentContext: AgentContext, proofExchangeId: string): Promise - findRequestMessage(agentContext: AgentContext, proofExchangeId: string): Promise - findPresentationMessage(agentContext: AgentContext, proofExchangeId: string): Promise + findProposalMessage(agentContext: AgentContext, proofExchangeId: string): Promise + findRequestMessage(agentContext: AgentContext, proofExchangeId: string): Promise + findPresentationMessage(agentContext: AgentContext, proofExchangeId: string): Promise getFormatData( agentContext: AgentContext, proofExchangeId: string diff --git a/packages/core/src/modules/proofs/protocol/ProofProtocolOptions.ts b/packages/core/src/modules/proofs/protocol/ProofProtocolOptions.ts index e84881f32a..a87ddd55cb 100644 --- a/packages/core/src/modules/proofs/protocol/ProofProtocolOptions.ts +++ b/packages/core/src/modules/proofs/protocol/ProofProtocolOptions.ts @@ -1,5 +1,5 @@ import type { ProofProtocol } from './ProofProtocol' -import type { DidCommV1Message } from '../../../didcomm' +import type { AgentBaseMessage } from '../../../agent/AgentBaseMessage' import type { ConnectionRecord } from '../../connections' import type { ExtractProofFormats, @@ -155,7 +155,7 @@ export interface CreateProofProblemReportOptions { description: string } -export interface ProofProtocolMsgReturnType { +export interface ProofProtocolMsgReturnType { message: MessageType proofRecord: ProofExchangeRecord } diff --git a/packages/core/src/modules/proofs/protocol/index.ts b/packages/core/src/modules/proofs/protocol/index.ts index 71799a5c45..9c2242ad5d 100644 --- a/packages/core/src/modules/proofs/protocol/index.ts +++ b/packages/core/src/modules/proofs/protocol/index.ts @@ -1,4 +1,5 @@ export * from './v2' +export * from './v3' import * as ProofProtocolOptions from './ProofProtocolOptions' export { ProofProtocol } from './ProofProtocol' diff --git a/packages/core/src/modules/proofs/protocol/v2/handlers/V2PresentationAckHandler.ts b/packages/core/src/modules/proofs/protocol/v2/handlers/V2PresentationAckHandler.ts index 43b9e15a69..38ad7ab2dc 100644 --- a/packages/core/src/modules/proofs/protocol/v2/handlers/V2PresentationAckHandler.ts +++ b/packages/core/src/modules/proofs/protocol/v2/handlers/V2PresentationAckHandler.ts @@ -1,13 +1,13 @@ import type { MessageHandler, MessageHandlerInboundMessage } from '../../../../../agent/MessageHandler' -import type { ProofProtocol } from '../../ProofProtocol' +import type { V2ProofProtocol } from '../V2ProofProtocol' import { V2PresentationAckMessage } from '../messages' export class V2PresentationAckHandler implements MessageHandler { - private proofProtocol: ProofProtocol + private proofProtocol: V2ProofProtocol public supportedMessages = [V2PresentationAckMessage] - public constructor(proofProtocol: ProofProtocol) { + public constructor(proofProtocol: V2ProofProtocol) { this.proofProtocol = proofProtocol } diff --git a/packages/core/src/modules/proofs/protocol/v3/ProofFormatCoordinator.ts b/packages/core/src/modules/proofs/protocol/v3/ProofFormatCoordinator.ts new file mode 100644 index 0000000000..8cac6b1fdf --- /dev/null +++ b/packages/core/src/modules/proofs/protocol/v3/ProofFormatCoordinator.ts @@ -0,0 +1,470 @@ +import type { AgentContext } from '../../../../agent' +import type { V2Attachment } from '../../../../decorators/attachment' +import type { + ExtractProofFormats, + ProofFormatCredentialForRequestPayload, + ProofFormatPayload, + ProofFormatService, +} from '../../formats' +import type { ProofExchangeRecord } from '../../repository' + +import { toV1Attachment, toV2Attachment } from '../../../../didcomm' +import { AriesFrameworkError } from '../../../../error' +import { DidCommMessageRepository, DidCommMessageRole } from '../../../../storage' + +import { V3PresentationMessage, V3ProposePresentationMessage, V3RequestPresentationMessage } from './messages' + +export class ProofFormatCoordinator { + /** + * Create a {@link V3ProposePresentationMessage}. + * + * @param options + * @returns The created {@link V3ProposePresentationMessage} + * + */ + public async createProposal( + agentContext: AgentContext, + { + proofFormats, + formatServices, + proofRecord, + comment, + goalCode, + }: { + formatServices: ProofFormatService[] + proofFormats: ProofFormatPayload, 'createProposal'> + proofRecord: ProofExchangeRecord + comment?: string + goalCode?: string + } + ): Promise { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + const proposalAttachments: V2Attachment[] = [] + + for (const formatService of formatServices) { + const { format, attachment } = await formatService.createProposal(agentContext, { + proofFormats, + proofRecord, + }) + + const v2Attachment = toV2Attachment(attachment) + v2Attachment.format = format.format + proposalAttachments.push(v2Attachment) + } + + const message = new V3ProposePresentationMessage({ + id: proofRecord.threadId, + attachments: proposalAttachments, + comment: comment, + goalCode, + }) + + message.setThread({ threadId: proofRecord.threadId, parentThreadId: proofRecord.parentThreadId }) + + await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { + agentMessage: message, + role: DidCommMessageRole.Sender, + associatedRecordId: proofRecord.id, + }) + + return message + } + + public async processProposal( + agentContext: AgentContext, + { + proofRecord, + message, + formatServices, + }: { + proofRecord: ProofExchangeRecord + message: V3ProposePresentationMessage + formatServices: ProofFormatService[] + } + ) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + for (const formatService of formatServices) { + const attachment = this.getAttachmentForService(formatService, message.attachments) + + await formatService.processProposal(agentContext, { + attachment, + proofRecord, + }) + } + + await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { + agentMessage: message, + role: DidCommMessageRole.Receiver, + associatedRecordId: proofRecord.id, + }) + } + + public async acceptProposal( + agentContext: AgentContext, + { + proofRecord, + proofFormats, + formatServices, + comment, + goalCode, + willConfirm, + }: { + proofRecord: ProofExchangeRecord + proofFormats?: ProofFormatPayload, 'acceptProposal'> + formatServices: ProofFormatService[] + comment?: string + goalCode?: string + willConfirm?: boolean + } + ) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + const requestAttachments: V2Attachment[] = [] + + const proposalMessage = await didCommMessageRepository.getAgentMessage(agentContext, { + associatedRecordId: proofRecord.id, + messageClass: V3ProposePresentationMessage, + }) + + for (const formatService of formatServices) { + const proposalAttachment = this.getAttachmentForService(formatService, proposalMessage.attachments) + + const { attachment, format } = await formatService.acceptProposal(agentContext, { + proofRecord, + proofFormats, + proposalAttachment, + }) + const v2Attachment = toV2Attachment(attachment) + v2Attachment.format = format.format + requestAttachments.push(v2Attachment) + } + + const message = new V3RequestPresentationMessage({ + attachments: requestAttachments, + comment, + goalCode, + willConfirm, + }) + + message.setThread({ threadId: proofRecord.threadId, parentThreadId: proofRecord.parentThreadId }) + + await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { + agentMessage: message, + associatedRecordId: proofRecord.id, + role: DidCommMessageRole.Sender, + }) + + return message + } + + /** + * Create a {@link V3RequestPresentationMessage}. + * + * @param options + * @returns The created {@link V3RequestPresentationMessage} + * + */ + public async createRequest( + agentContext: AgentContext, + { + proofFormats, + formatServices, + proofRecord, + comment, + goalCode, + willConfirm, + }: { + formatServices: ProofFormatService[] + proofFormats: ProofFormatPayload, 'createRequest'> + proofRecord: ProofExchangeRecord + comment?: string + goalCode?: string + willConfirm?: boolean + } + ): Promise { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + const requestAttachments: V2Attachment[] = [] + + for (const formatService of formatServices) { + const { format, attachment } = await formatService.createRequest(agentContext, { + proofFormats, + proofRecord, + }) + + const v2Attachment = toV2Attachment(attachment) + v2Attachment.format = format.format + requestAttachments.push(v2Attachment) + } + + const message = new V3RequestPresentationMessage({ + comment, + attachments: requestAttachments, + goalCode, + willConfirm, + }) + + message.setThread({ threadId: proofRecord.threadId, parentThreadId: proofRecord.parentThreadId }) + + await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { + agentMessage: message, + role: DidCommMessageRole.Sender, + associatedRecordId: proofRecord.id, + }) + + return message + } + + public async processRequest( + agentContext: AgentContext, + { + proofRecord, + message, + formatServices, + }: { + proofRecord: ProofExchangeRecord + message: V3RequestPresentationMessage + formatServices: ProofFormatService[] + } + ) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + for (const formatService of formatServices) { + const attachment = this.getAttachmentForService(formatService, message.attachments) + + await formatService.processRequest(agentContext, { + attachment, + proofRecord, + }) + } + + await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { + agentMessage: message, + role: DidCommMessageRole.Receiver, + associatedRecordId: proofRecord.id, + }) + } + + public async acceptRequest( + agentContext: AgentContext, + { + proofRecord, + proofFormats, + formatServices, + comment, + goalCode, + }: { + proofRecord: ProofExchangeRecord + proofFormats?: ProofFormatPayload, 'acceptRequest'> + formatServices: ProofFormatService[] + comment?: string + goalCode?: string + } + ) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + const requestMessage = await didCommMessageRepository.getAgentMessage(agentContext, { + associatedRecordId: proofRecord.id, + messageClass: V3RequestPresentationMessage, + }) + + const proposalMessage = await didCommMessageRepository.findAgentMessage(agentContext, { + associatedRecordId: proofRecord.id, + messageClass: V3ProposePresentationMessage, + }) + + const presentationAttachments: V2Attachment[] = [] + + for (const formatService of formatServices) { + const requestAttachment = this.getAttachmentForService(formatService, requestMessage.attachments) + + const proposalAttachment = proposalMessage + ? this.getAttachmentForService(formatService, proposalMessage.attachments) + : undefined + + const { attachment, format } = await formatService.acceptRequest(agentContext, { + requestAttachment, + proposalAttachment, + proofRecord, + proofFormats, + }) + + const v2Attachment = toV2Attachment(attachment) + v2Attachment.format = format.format + presentationAttachments.push(v2Attachment) + } + + const message = new V3PresentationMessage({ + attachments: presentationAttachments, + comment, + goalCode, + }) + + message.setThread({ threadId: proofRecord.threadId }) + //TODO: message.setPleaseAck() + + await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { + agentMessage: message, + associatedRecordId: proofRecord.id, + role: DidCommMessageRole.Sender, + }) + + return message + } + + public async getCredentialsForRequest( + agentContext: AgentContext, + { + proofRecord, + proofFormats, + formatServices, + }: { + proofRecord: ProofExchangeRecord + proofFormats?: ProofFormatCredentialForRequestPayload< + ExtractProofFormats, + 'getCredentialsForRequest', + 'input' + > + formatServices: ProofFormatService[] + } + ): Promise, 'getCredentialsForRequest', 'output'>> { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + const requestMessage = await didCommMessageRepository.getAgentMessage(agentContext, { + associatedRecordId: proofRecord.id, + messageClass: V3RequestPresentationMessage, + }) + + const proposalMessage = await didCommMessageRepository.findAgentMessage(agentContext, { + associatedRecordId: proofRecord.id, + messageClass: V3ProposePresentationMessage, + }) + + const credentialsForRequest: Record = {} + + for (const formatService of formatServices) { + const requestAttachment = this.getAttachmentForService(formatService, requestMessage.attachments) + + const proposalAttachment = proposalMessage + ? this.getAttachmentForService(formatService, proposalMessage.attachments) + : undefined + + const credentialsForFormat = await formatService.getCredentialsForRequest(agentContext, { + requestAttachment, + proposalAttachment, + proofRecord, + proofFormats, + }) + + credentialsForRequest[formatService.formatKey] = credentialsForFormat + } + + return credentialsForRequest + } + + public async selectCredentialsForRequest( + agentContext: AgentContext, + { + proofRecord, + proofFormats, + formatServices, + }: { + proofRecord: ProofExchangeRecord + proofFormats?: ProofFormatCredentialForRequestPayload< + ExtractProofFormats, + 'selectCredentialsForRequest', + 'input' + > + formatServices: ProofFormatService[] + } + ): Promise< + ProofFormatCredentialForRequestPayload, 'selectCredentialsForRequest', 'output'> + > { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + const requestMessage = await didCommMessageRepository.getAgentMessage(agentContext, { + associatedRecordId: proofRecord.id, + messageClass: V3RequestPresentationMessage, + }) + + const proposalMessage = await didCommMessageRepository.findAgentMessage(agentContext, { + associatedRecordId: proofRecord.id, + messageClass: V3ProposePresentationMessage, + }) + + const credentialsForRequest: Record = {} + + for (const formatService of formatServices) { + const requestAttachment = this.getAttachmentForService(formatService, requestMessage.attachments) + + const proposalAttachment = proposalMessage + ? this.getAttachmentForService(formatService, proposalMessage.attachments) + : undefined + + const credentialsForFormat = await formatService.selectCredentialsForRequest(agentContext, { + requestAttachment, + proposalAttachment, + proofRecord, + proofFormats, + }) + + credentialsForRequest[formatService.formatKey] = credentialsForFormat + } + + return credentialsForRequest + } + + public async processPresentation( + agentContext: AgentContext, + { + proofRecord, + message, + requestMessage, + formatServices, + }: { + proofRecord: ProofExchangeRecord + message: V3PresentationMessage + requestMessage: V3RequestPresentationMessage + formatServices: ProofFormatService[] + } + ) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + const formatVerificationResults: boolean[] = [] + + for (const formatService of formatServices) { + const attachment = this.getAttachmentForService(formatService, message.attachments) + const requestAttachment = this.getAttachmentForService(formatService, requestMessage.attachments) + + const isValid = await formatService.processPresentation(agentContext, { + attachment, + requestAttachment, + proofRecord, + }) + + formatVerificationResults.push(isValid) + } + + await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { + agentMessage: message, + role: DidCommMessageRole.Receiver, + associatedRecordId: proofRecord.id, + }) + + return formatVerificationResults.every((isValid) => isValid === true) + } + + public getAttachmentForService(proofFormatService: ProofFormatService, attachments: V2Attachment[]) { + const attachment = attachments.find( + (attachment) => attachment.format && proofFormatService.supportsFormat(attachment.format) + ) + + if (!attachment) { + throw new AriesFrameworkError(`Attachment with format ${proofFormatService.formatKey} not found in attachments.`) + } + + return toV1Attachment(attachment) + } +} diff --git a/packages/core/src/modules/proofs/protocol/v3/V3ProofProtocol.ts b/packages/core/src/modules/proofs/protocol/v3/V3ProofProtocol.ts new file mode 100644 index 0000000000..47e6e4e79d --- /dev/null +++ b/packages/core/src/modules/proofs/protocol/v3/V3ProofProtocol.ts @@ -0,0 +1,972 @@ +import type { AgentContext } from '../../../../agent' +import type { FeatureRegistry } from '../../../../agent/FeatureRegistry' +import type { InboundMessageContext } from '../../../../agent/models/InboundMessageContext' +import type { V2Attachment } from '../../../../decorators/attachment' +import type { DidCommV2Message } from '../../../../didcomm' +import type { DependencyManager } from '../../../../plugins' +import type { V2ProblemReportMessage } from '../../../problem-reports' +import type { + ExtractProofFormats, + ProofFormat, + ProofFormatCredentialForRequestPayload, + ProofFormatPayload, +} from '../../formats' +import type { ProofFormatService } from '../../formats/ProofFormatService' +import type { ProofProtocol } from '../ProofProtocol' +import type { + AcceptPresentationOptions, + AcceptProofProposalOptions, + AcceptProofRequestOptions, + CreateProofProblemReportOptions, + CreateProofProposalOptions, + CreateProofRequestOptions, + ProofFormatDataMessagePayload, + GetCredentialsForRequestOptions, + GetCredentialsForRequestReturn, + GetProofFormatDataReturn, + NegotiateProofProposalOptions, + NegotiateProofRequestOptions, + ProofProtocolMsgReturnType, + SelectCredentialsForRequestOptions, + SelectCredentialsForRequestReturn, +} from '../ProofProtocolOptions' + +import { Protocol } from '../../../../agent/models' +import { AriesFrameworkError } from '../../../../error' +import { DidCommMessageRepository } from '../../../../storage' +import { uuid } from '../../../../utils/uuid' +import { ProofsModuleConfig } from '../../ProofsModuleConfig' +import { PresentationProblemReportReason } from '../../errors/PresentationProblemReportReason' +import { AutoAcceptProof, ProofState } from '../../models' +import { ProofExchangeRecord, ProofRepository } from '../../repository' +import { composeAutoAccept } from '../../utils/composeAutoAccept' +import { BaseProofProtocol } from '../BaseProofProtocol' + +import { ProofFormatCoordinator } from './ProofFormatCoordinator' +import { V3PresentationAckHandler } from './handlers/V3PresentationAckHandler' +import { V3PresentationHandler } from './handlers/V3PresentationHandler' +import { V3PresentationProblemReportHandler } from './handlers/V3PresentationProblemReportHandler' +import { V3ProposePresentationHandler } from './handlers/V3ProposePresentationHandler' +import { V3RequestPresentationHandler } from './handlers/V3RequestPresentationHandler' +import { + V3ProposePresentationMessage, + V3PresentationProblemReportMessage, + V3PresentationMessage, + V3PresentationAckMessage, + V3RequestPresentationMessage, +} from './messages' + +export interface V3ProofProtocolConfig { + proofFormats: ProofFormatServices +} + +export class V3ProofProtocol + extends BaseProofProtocol + implements ProofProtocol +{ + private proofFormatCoordinator = new ProofFormatCoordinator() + private proofFormats: PFs + + public constructor({ proofFormats }: V3ProofProtocolConfig) { + super() + + this.proofFormats = proofFormats + } + + /** + * The version of the present proof protocol this service supports + */ + public readonly version = 'v3' as const + + public register(dependencyManager: DependencyManager, featureRegistry: FeatureRegistry) { + // Register message handlers for the Present Proof V3 Protocol + dependencyManager.registerMessageHandlers([ + new V3ProposePresentationHandler(this), + new V3RequestPresentationHandler(this), + new V3PresentationHandler(this), + new V3PresentationAckHandler(this), + new V3PresentationProblemReportHandler(this), + ]) + + // Register Present Proof V2 in feature registry, with supported roles + featureRegistry.register( + new Protocol({ + id: 'https://didcomm.org/present-proof/3.0', + roles: ['prover', 'verifier'], + }) + ) + } + + public async createProposal( + agentContext: AgentContext, + { + connectionRecord, + proofFormats, + comment, + autoAcceptProof, + goalCode, + parentThreadId, + }: CreateProofProposalOptions + ): Promise> { + const proofRepository = agentContext.dependencyManager.resolve(ProofRepository) + + const formatServices = this.getFormatServices(proofFormats) + if (formatServices.length === 0) { + throw new AriesFrameworkError(`Unable to create proposal. No supported formats`) + } + + const proofRecord = new ProofExchangeRecord({ + connectionId: connectionRecord.id, + threadId: uuid(), + parentThreadId, + state: ProofState.ProposalSent, + protocolVersion: 'v3', + autoAcceptProof, + }) + + const proposalMessage = await this.proofFormatCoordinator.createProposal(agentContext, { + proofFormats, + proofRecord, + formatServices, + comment, + goalCode, + }) + + agentContext.config.logger.debug('Save record and emit state change event') + await proofRepository.save(agentContext, proofRecord) + this.emitStateChangedEvent(agentContext, proofRecord, null) + + return { + proofRecord, + message: proposalMessage, + } + } + + /** + * Method called by {@link V2ProposeCredentialHandler} on reception of a propose presentation message + * We do the necessary processing here to accept the proposal and do the state change, emit event etc. + * @param messageContext the inbound propose presentation message + * @returns proof record appropriate for this incoming message (once accepted) + */ + public async processProposal( + messageContext: InboundMessageContext + ): Promise { + const { message: proposalMessage, connection, agentContext } = messageContext + + agentContext.config.logger.debug(`Processing presentation proposal with id ${proposalMessage.id}`) + + const proofRepository = agentContext.dependencyManager.resolve(ProofRepository) + + let proofRecord = await this.findByThreadAndConnectionId( + messageContext.agentContext, + proposalMessage.threadId, + connection?.id + ) + + const formatServices = this.getFormatServicesFromAttachments(proposalMessage.attachments) + + if (formatServices.length === 0) { + throw new AriesFrameworkError(`Unable to process proposal. No supported formats`) + } + + // credential record already exists + if (proofRecord) { + // Assert + proofRecord.assertProtocolVersion('v3') + proofRecord.assertState(ProofState.RequestSent) + + await this.proofFormatCoordinator.processProposal(messageContext.agentContext, { + proofRecord, + formatServices, + message: proposalMessage, + }) + + await this.updateState(messageContext.agentContext, proofRecord, ProofState.ProposalReceived) + + return proofRecord + } else { + // No proof record exists with thread id + proofRecord = new ProofExchangeRecord({ + connectionId: connection?.id, + threadId: proposalMessage.threadId, + state: ProofState.ProposalReceived, + protocolVersion: 'v3', + parentThreadId: proposalMessage.parentThreadId, + }) + + await this.proofFormatCoordinator.processProposal(messageContext.agentContext, { + proofRecord, + formatServices, + message: proposalMessage, + }) + + // Save record and emit event + await proofRepository.save(messageContext.agentContext, proofRecord) + this.emitStateChangedEvent(messageContext.agentContext, proofRecord, null) + + return proofRecord + } + } + + public async acceptProposal( + agentContext: AgentContext, + { proofRecord, proofFormats, autoAcceptProof, comment, goalCode, willConfirm }: AcceptProofProposalOptions + ): Promise> { + // Assert + proofRecord.assertProtocolVersion('v3') + proofRecord.assertState(ProofState.ProposalReceived) + + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + // Use empty proofFormats if not provided to denote all formats should be accepted + let formatServices = this.getFormatServices(proofFormats ?? {}) + + // if no format services could be extracted from the proofFormats + // take all available format services from the proposal message + if (formatServices.length === 0) { + const proposalMessage = await didCommMessageRepository.getAgentMessage(agentContext, { + associatedRecordId: proofRecord.id, + messageClass: V3ProposePresentationMessage, + }) + + formatServices = this.getFormatServicesFromAttachments(proposalMessage.attachments) + } + + // If the format services list is still empty, throw an error as we don't support any + // of the formats + if (formatServices.length === 0) { + throw new AriesFrameworkError( + `Unable to accept proposal. No supported formats provided as input or in proposal message` + ) + } + + const requestMessage = await this.proofFormatCoordinator.acceptProposal(agentContext, { + proofRecord, + formatServices, + comment, + proofFormats, + goalCode, + willConfirm, + }) + + proofRecord.autoAcceptProof = autoAcceptProof ?? proofRecord.autoAcceptProof + await this.updateState(agentContext, proofRecord, ProofState.RequestSent) + + return { proofRecord, message: requestMessage } + } + + /** + * Negotiate a proof proposal as verifier (by sending a proof request message) to the connection + * associated with the proof record. + * + * @param options configuration for the request see {@link NegotiateProofProposalOptions} + * @returns Proof exchange record associated with the proof request + * + */ + public async negotiateProposal( + agentContext: AgentContext, + { proofRecord, proofFormats, autoAcceptProof, comment, goalCode, willConfirm }: NegotiateProofProposalOptions + ): Promise> { + // Assert + proofRecord.assertProtocolVersion('v3') + proofRecord.assertState(ProofState.ProposalReceived) + + if (!proofRecord.connectionId) { + throw new AriesFrameworkError( + `No connectionId found for proof record '${proofRecord.id}'. Connection-less verification does not support negotiation.` + ) + } + + const formatServices = this.getFormatServices(proofFormats) + if (formatServices.length === 0) { + throw new AriesFrameworkError(`Unable to create request. No supported formats`) + } + + const requestMessage = await this.proofFormatCoordinator.createRequest(agentContext, { + formatServices, + proofFormats, + proofRecord, + comment, + goalCode, + willConfirm, + }) + + proofRecord.autoAcceptProof = autoAcceptProof ?? proofRecord.autoAcceptProof + await this.updateState(agentContext, proofRecord, ProofState.RequestSent) + + return { proofRecord, message: requestMessage } + } + + /** + * Create a {@link V3RequestPresentationMessage} as beginning of protocol process. + * @returns Object containing request message and associated credential record + * + */ + public async createRequest( + agentContext: AgentContext, + { + proofFormats, + autoAcceptProof, + comment, + connectionRecord, + parentThreadId, + goalCode, + willConfirm, + }: CreateProofRequestOptions + ): Promise> { + const proofRepository = agentContext.dependencyManager.resolve(ProofRepository) + + const formatServices = this.getFormatServices(proofFormats) + if (formatServices.length === 0) { + throw new AriesFrameworkError(`Unable to create request. No supported formats`) + } + + const proofRecord = new ProofExchangeRecord({ + connectionId: connectionRecord?.id, + threadId: uuid(), + state: ProofState.RequestSent, + autoAcceptProof, + protocolVersion: 'v3', + parentThreadId, + }) + + const requestMessage = await this.proofFormatCoordinator.createRequest(agentContext, { + formatServices, + proofFormats, + proofRecord, + comment, + goalCode, + willConfirm, + }) + + agentContext.config.logger.debug( + `Saving record and emitting state changed for proof exchange record ${proofRecord.id}` + ) + await proofRepository.save(agentContext, proofRecord) + this.emitStateChangedEvent(agentContext, proofRecord, null) + + return { proofRecord, message: requestMessage } + } + + /** + * Process a received {@link V3RequestPresentationMessage}. This will not accept the proof request + * or send a proof. It will only update the existing proof record with + * the information from the proof request message. Use {@link createCredential} + * after calling this method to create a proof. + *z + * @param messageContext The message context containing a v2 proof request message + * @returns proof record associated with the proof request message + * + */ + public async processRequest( + messageContext: InboundMessageContext + ): Promise { + const { message: requestMessage, connection, agentContext } = messageContext + + const proofRepository = agentContext.dependencyManager.resolve(ProofRepository) + + agentContext.config.logger.debug(`Processing proof request with id ${requestMessage.id}`) + + let proofRecord = await this.findByThreadAndConnectionId( + messageContext.agentContext, + requestMessage.threadId, + connection?.id + ) + + const formatServices = this.getFormatServicesFromAttachments(requestMessage.attachments) + if (formatServices.length === 0) { + throw new AriesFrameworkError(`Unable to process request. No supported formats`) + } + + // proof record already exists + if (proofRecord) { + // Assert + proofRecord.assertProtocolVersion('v3') + proofRecord.assertState(ProofState.ProposalSent) + await this.proofFormatCoordinator.processRequest(messageContext.agentContext, { + proofRecord, + formatServices, + message: requestMessage, + }) + + await this.updateState(messageContext.agentContext, proofRecord, ProofState.RequestReceived) + return proofRecord + } else { + // No proof record exists with thread id + agentContext.config.logger.debug('No proof record found for request, creating a new one') + proofRecord = new ProofExchangeRecord({ + connectionId: connection?.id, + threadId: requestMessage.threadId, + state: ProofState.RequestReceived, + protocolVersion: 'v3', + parentThreadId: requestMessage.parentThreadId, + }) + + await this.proofFormatCoordinator.processRequest(messageContext.agentContext, { + proofRecord, + formatServices, + message: requestMessage, + }) + + // Save in repository + agentContext.config.logger.debug('Saving proof record and emit request-received event') + await proofRepository.save(messageContext.agentContext, proofRecord) + + this.emitStateChangedEvent(messageContext.agentContext, proofRecord, null) + return proofRecord + } + } + + public async acceptRequest( + agentContext: AgentContext, + { proofRecord, autoAcceptProof, comment, proofFormats, goalCode }: AcceptProofRequestOptions + ) { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + // Assert + proofRecord.assertProtocolVersion('v3') + proofRecord.assertState(ProofState.RequestReceived) + + // Use empty proofFormats if not provided to denote all formats should be accepted + let formatServices = this.getFormatServices(proofFormats ?? {}) + + // if no format services could be extracted from the proofFormats + // take all available format services from the request message + if (formatServices.length === 0) { + const requestMessage = await didCommMessageRepository.getAgentMessage(agentContext, { + associatedRecordId: proofRecord.id, + messageClass: V3RequestPresentationMessage, + }) + + formatServices = this.getFormatServicesFromAttachments(requestMessage.attachments) + } + + // If the format services list is still empty, throw an error as we don't support any + // of the formats + if (formatServices.length === 0) { + throw new AriesFrameworkError( + `Unable to accept request. No supported formats provided as input or in request message` + ) + } + const message = await this.proofFormatCoordinator.acceptRequest(agentContext, { + proofRecord, + formatServices, + comment, + proofFormats, + goalCode, + }) + + proofRecord.autoAcceptProof = autoAcceptProof ?? proofRecord.autoAcceptProof + await this.updateState(agentContext, proofRecord, ProofState.PresentationSent) + + return { proofRecord, message } + } + + /** + * Create a {@link V3ProposePresentationMessage} as response to a received credential request. + * To create a proposal not bound to an existing proof exchange, use {@link createProposal}. + * + * @param options configuration to use for the proposal + * @returns Object containing proposal message and associated proof record + * + */ + public async negotiateRequest( + agentContext: AgentContext, + { proofRecord, proofFormats, autoAcceptProof, comment, goalCode }: NegotiateProofRequestOptions + ): Promise> { + // Assert + proofRecord.assertProtocolVersion('v3') + proofRecord.assertState(ProofState.RequestReceived) + + if (!proofRecord.connectionId) { + throw new AriesFrameworkError( + `No connectionId found for proof record '${proofRecord.id}'. Connection-less verification does not support negotiation.` + ) + } + + const formatServices = this.getFormatServices(proofFormats) + if (formatServices.length === 0) { + throw new AriesFrameworkError(`Unable to create proposal. No supported formats`) + } + + const proposalMessage = await this.proofFormatCoordinator.createProposal(agentContext, { + formatServices, + proofFormats, + proofRecord, + comment, + goalCode, + }) + + proofRecord.autoAcceptProof = autoAcceptProof ?? proofRecord.autoAcceptProof + await this.updateState(agentContext, proofRecord, ProofState.ProposalSent) + + return { proofRecord, message: proposalMessage } + } + + public async getCredentialsForRequest( + agentContext: AgentContext, + { proofRecord, proofFormats }: GetCredentialsForRequestOptions + ): Promise> { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + // Assert + proofRecord.assertProtocolVersion('v3') + proofRecord.assertState(ProofState.RequestReceived) + + // Use empty proofFormats if not provided to denote all formats should be accepted + let formatServices = this.getFormatServices(proofFormats ?? {}) + + // if no format services could be extracted from the proofFormats + // take all available format services from the request message + if (formatServices.length === 0) { + const requestMessage = await didCommMessageRepository.getAgentMessage(agentContext, { + associatedRecordId: proofRecord.id, + messageClass: V3RequestPresentationMessage, + }) + + formatServices = this.getFormatServicesFromAttachments(requestMessage.attachments) + } + + // If the format services list is still empty, throw an error as we don't support any + // of the formats + if (formatServices.length === 0) { + throw new AriesFrameworkError( + `Unable to get credentials for request. No supported formats provided as input or in request message` + ) + } + + const result = await this.proofFormatCoordinator.getCredentialsForRequest(agentContext, { + formatServices, + proofFormats, + proofRecord, + }) + + return { + proofFormats: result, + } + } + + public async selectCredentialsForRequest( + agentContext: AgentContext, + { proofRecord, proofFormats }: SelectCredentialsForRequestOptions + ): Promise> { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + // Assert + proofRecord.assertProtocolVersion('v3') + proofRecord.assertState(ProofState.RequestReceived) + + // Use empty proofFormats if not provided to denote all formats should be accepted + let formatServices = this.getFormatServices(proofFormats ?? {}) + + // if no format services could be extracted from the proofFormats + // take all available format services from the request message + if (formatServices.length === 0) { + const requestMessage = await didCommMessageRepository.getAgentMessage(agentContext, { + associatedRecordId: proofRecord.id, + messageClass: V3RequestPresentationMessage, + }) + + formatServices = this.getFormatServicesFromAttachments(requestMessage.attachments) + } + + // If the format services list is still empty, throw an error as we don't support any + // of the formats + if (formatServices.length === 0) { + throw new AriesFrameworkError( + `Unable to get credentials for request. No supported formats provided as input or in request message` + ) + } + + const result = await this.proofFormatCoordinator.selectCredentialsForRequest(agentContext, { + formatServices, + proofFormats, + proofRecord, + }) + + return { + proofFormats: result, + } + } + + public async processPresentation( + messageContext: InboundMessageContext + ): Promise { + const { message: presentationMessage, connection, agentContext } = messageContext + + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + agentContext.config.logger.debug(`Processing presentation with id ${presentationMessage.id}`) + + const proofRecord = await this.getByThreadAndConnectionId( + messageContext.agentContext, + presentationMessage.threadId, + connection?.id + ) + + const requestMessage = await didCommMessageRepository.getAgentMessage(messageContext.agentContext, { + associatedRecordId: proofRecord.id, + messageClass: V3RequestPresentationMessage, + }) + + // Assert + proofRecord.assertProtocolVersion('v3') + proofRecord.assertState(ProofState.RequestSent) + + const formatServices = this.getFormatServicesFromAttachments(presentationMessage.attachments) + if (formatServices.length === 0) { + throw new AriesFrameworkError(`Unable to process presentation. No supported formats`) + } + + const isValid = await this.proofFormatCoordinator.processPresentation(messageContext.agentContext, { + proofRecord, + formatServices, + requestMessage, + message: presentationMessage, + }) + + proofRecord.isVerified = isValid + await this.updateState(messageContext.agentContext, proofRecord, ProofState.PresentationReceived) + + return proofRecord + } + + public async acceptPresentation( + agentContext: AgentContext, + { proofRecord }: AcceptPresentationOptions + ): Promise> { + proofRecord.assertProtocolVersion('v3') + proofRecord.assertState(ProofState.PresentationReceived) + + const message = new V3PresentationAckMessage({ + threadId: proofRecord.threadId, + }) + + await this.updateState(agentContext, proofRecord, ProofState.Done) + + return { + message, + proofRecord, + } + } + + public async processAck( + messageContext: InboundMessageContext + ): Promise { + const { message: ackMessage, connection, agentContext } = messageContext + + agentContext.config.logger.debug(`Processing proof ack with id ${ackMessage.id}`) + + const proofRecord = await this.getByThreadAndConnectionId( + messageContext.agentContext, + ackMessage.threadId, + connection?.id + ) + proofRecord.connectionId = connection?.id + + // Assert + proofRecord.assertProtocolVersion('v3') + proofRecord.assertState(ProofState.PresentationSent) + + // Update record + await this.updateState(messageContext.agentContext, proofRecord, ProofState.Done) + + return proofRecord + } + + public async createProblemReport( + agentContext: AgentContext, + { description, proofRecord }: CreateProofProblemReportOptions + ): Promise> { + const message = new V3PresentationProblemReportMessage({ + parentThreadId: proofRecord.threadId, + body: { + comment: description, + code: PresentationProblemReportReason.Abandoned, + }, + }) + + message.setThread({ + threadId: proofRecord.threadId, + parentThreadId: proofRecord.parentThreadId, + }) + + return { + proofRecord, + message, + } + } + + public async shouldAutoRespondToProposal( + agentContext: AgentContext, + options: { + proofRecord: ProofExchangeRecord + proposalMessage: V3ProposePresentationMessage + } + ): Promise { + const { proofRecord, proposalMessage } = options + const proofsModuleConfig = agentContext.dependencyManager.resolve(ProofsModuleConfig) + + const autoAccept = composeAutoAccept(proofRecord.autoAcceptProof, proofsModuleConfig.autoAcceptProofs) + + // Handle always / never cases + if (autoAccept === AutoAcceptProof.Always) return true + if (autoAccept === AutoAcceptProof.Never) return false + + const requestMessage = await this.findRequestMessage(agentContext, proofRecord.id) + if (!requestMessage) return false + + // NOTE: we take the formats from the requestMessage so we always check all services that we last sent + // Otherwise we'll only check the formats from the proposal, which could be different from the formats + // we use. + const formatServices = this.getFormatServicesFromAttachments(requestMessage.attachments) + + for (const formatService of formatServices) { + const requestAttachment = this.proofFormatCoordinator.getAttachmentForService( + formatService, + requestMessage.attachments + ) + + const proposalAttachment = this.proofFormatCoordinator.getAttachmentForService( + formatService, + proposalMessage.attachments + ) + + const shouldAutoRespondToFormat = await formatService.shouldAutoRespondToProposal(agentContext, { + proofRecord, + requestAttachment, + proposalAttachment, + }) + // If any of the formats return false, we should not auto accept + if (!shouldAutoRespondToFormat) return false + } + + return true + } + + public async shouldAutoRespondToRequest( + agentContext: AgentContext, + options: { + proofRecord: ProofExchangeRecord + requestMessage: V3RequestPresentationMessage + } + ): Promise { + const { proofRecord, requestMessage } = options + const proofsModuleConfig = agentContext.dependencyManager.resolve(ProofsModuleConfig) + + const autoAccept = composeAutoAccept(proofRecord.autoAcceptProof, proofsModuleConfig.autoAcceptProofs) + + // Handle always / never cases + if (autoAccept === AutoAcceptProof.Always) return true + if (autoAccept === AutoAcceptProof.Never) return false + + const proposalMessage = await this.findProposalMessage(agentContext, proofRecord.id) + if (!proposalMessage) return false + + // NOTE: we take the formats from the proposalMessage so we always check all services that we last sent + // Otherwise we'll only check the formats from the request, which could be different from the formats + // we use. + const formatServices = this.getFormatServicesFromAttachments(proposalMessage.attachments) + + for (const formatService of formatServices) { + const proposalAttachment = this.proofFormatCoordinator.getAttachmentForService( + formatService, + proposalMessage.attachments + ) + + const requestAttachment = this.proofFormatCoordinator.getAttachmentForService( + formatService, + requestMessage.attachments + ) + + const shouldAutoRespondToFormat = await formatService.shouldAutoRespondToRequest(agentContext, { + proofRecord, + requestAttachment, + proposalAttachment, + }) + + // If any of the formats return false, we should not auto accept + if (!shouldAutoRespondToFormat) return false + } + + return true + } + + public async shouldAutoRespondToPresentation( + agentContext: AgentContext, + options: { proofRecord: ProofExchangeRecord; presentationMessage: V3PresentationMessage } + ): Promise { + const { proofRecord, presentationMessage } = options + const proofsModuleConfig = agentContext.dependencyManager.resolve(ProofsModuleConfig) + + const autoAccept = composeAutoAccept(proofRecord.autoAcceptProof, proofsModuleConfig.autoAcceptProofs) + + // Handle always / never cases + if (autoAccept === AutoAcceptProof.Always) return true + if (autoAccept === AutoAcceptProof.Never) return false + + const proposalMessage = await this.findProposalMessage(agentContext, proofRecord.id) + + const requestMessage = await this.findRequestMessage(agentContext, proofRecord.id) + if (!requestMessage) return false + if (!requestMessage.body.willConfirm) return false + + // NOTE: we take the formats from the requestMessage so we always check all services that we last sent + // Otherwise we'll only check the formats from the credential, which could be different from the formats + // we use. + const formatServices = this.getFormatServicesFromAttachments(requestMessage.attachments) + + for (const formatService of formatServices) { + const proposalAttachment = proposalMessage + ? this.proofFormatCoordinator.getAttachmentForService(formatService, proposalMessage.attachments) + : undefined + + const requestAttachment = this.proofFormatCoordinator.getAttachmentForService( + formatService, + requestMessage.attachments + ) + + const presentationAttachment = this.proofFormatCoordinator.getAttachmentForService( + formatService, + presentationMessage.attachments + ) + + const shouldAutoRespondToFormat = await formatService.shouldAutoRespondToPresentation(agentContext, { + proofRecord, + presentationAttachment, + requestAttachment, + proposalAttachment, + }) + + // If any of the formats return false, we should not auto accept + if (!shouldAutoRespondToFormat) return false + } + return true + } + + public async findRequestMessage( + agentContext: AgentContext, + proofRecordId: string + ): Promise { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + return await didCommMessageRepository.findAgentMessage(agentContext, { + associatedRecordId: proofRecordId, + messageClass: V3RequestPresentationMessage, + }) + } + + public async findPresentationMessage( + agentContext: AgentContext, + proofRecordId: string + ): Promise { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + return await didCommMessageRepository.findAgentMessage(agentContext, { + associatedRecordId: proofRecordId, + messageClass: V3PresentationMessage, + }) + } + + public async findProposalMessage( + agentContext: AgentContext, + proofRecordId: string + ): Promise { + const didCommMessageRepository = agentContext.dependencyManager.resolve(DidCommMessageRepository) + + return await didCommMessageRepository.findAgentMessage(agentContext, { + associatedRecordId: proofRecordId, + messageClass: V3ProposePresentationMessage, + }) + } + + public async getFormatData(agentContext: AgentContext, proofRecordId: string): Promise { + // TODO: we could looking at fetching all record using a single query and then filtering based on the type of the message. + const [proposalMessage, requestMessage, presentationMessage] = await Promise.all([ + this.findProposalMessage(agentContext, proofRecordId), + this.findRequestMessage(agentContext, proofRecordId), + this.findPresentationMessage(agentContext, proofRecordId), + ]) + + // Create object with the keys and the message formats/attachments. We can then loop over this in a generic + // way so we don't have to add the same operation code four times + const messages = { + proposal: proposalMessage?.attachments, + request: requestMessage?.attachments, + presentation: presentationMessage?.attachments, + } as const + + const formatData: GetProofFormatDataReturn = {} + + // We loop through all of the message keys as defined above + for (const [messageKey, attachments] of Object.entries(messages)) { + // Message can be undefined, so we continue if it is not defined + if (!attachments) continue + + // Find all format services associated with the message + const formatServices = this.getFormatServicesFromAttachments(attachments) + + const messageFormatData: ProofFormatDataMessagePayload = {} + + // Loop through all of the format services, for each we will extract the attachment data and assign this to the object + // using the unique format key (e.g. indy) + for (const formatService of formatServices) { + const attachment = this.proofFormatCoordinator.getAttachmentForService(formatService, attachments) + messageFormatData[formatService.formatKey] = attachment.getDataAsJson() + } + + formatData[messageKey as keyof GetProofFormatDataReturn] = messageFormatData + } + + return formatData + } + + /** + * Get all the format service objects for a given proof format from an incoming message + * @param attachments the attachment objects containing the format name (eg indy) + * @return the proof format service objects in an array - derived from format object keys + */ + private getFormatServicesFromAttachments(attachments: V2Attachment[]): ProofFormatService[] { + const formatServices = new Set() + + for (const attachment of attachments) { + const service = attachment.format ? this.getFormatServiceForFormat(attachment.format) : undefined + if (service) formatServices.add(service) + } + + return Array.from(formatServices) + } + + /** + * Get all the format service objects for a given proof format + * @param proofFormats the format object containing various optional parameters + * @return the proof format service objects in an array - derived from format object keys + */ + private getFormatServices( + proofFormats: M extends 'selectCredentialsForRequest' | 'getCredentialsForRequest' + ? ProofFormatCredentialForRequestPayload, M, 'input'> + : ProofFormatPayload, M> + ): ProofFormatService[] { + const formats = new Set() + + for (const formatKey of Object.keys(proofFormats)) { + const formatService = this.getFormatServiceForFormatKey(formatKey) + + if (formatService) formats.add(formatService) + } + + return Array.from(formats) + } + + private getFormatServiceForFormatKey(formatKey: string): ProofFormatService | null { + const formatService = this.proofFormats.find((proofFormats) => proofFormats.formatKey === formatKey) + + return formatService ?? null + } + + private getFormatServiceForFormat(format: string): ProofFormatService | null { + const formatService = this.proofFormats.find((proofFormats) => proofFormats.supportsFormat(format)) + + return formatService ?? null + } +} diff --git a/packages/core/src/modules/proofs/protocol/v3/errors/V3PresentationProblemReportError.ts b/packages/core/src/modules/proofs/protocol/v3/errors/V3PresentationProblemReportError.ts new file mode 100644 index 0000000000..c8fba69429 --- /dev/null +++ b/packages/core/src/modules/proofs/protocol/v3/errors/V3PresentationProblemReportError.ts @@ -0,0 +1,25 @@ +import type { ProblemReportErrorOptions } from '../../../../problem-reports' +import type { PresentationProblemReportReason } from '../../../errors/PresentationProblemReportReason' + +import { V2ProblemReportError } from '../../../../problem-reports/errors/V2ProblemReportError' +import { V3PresentationProblemReportMessage as V3PresentationProblemReportMessage } from '../messages' + +interface V3PresentationProblemReportErrorOptions extends ProblemReportErrorOptions { + problemCode: PresentationProblemReportReason + parentThreadId: string +} + +export class V3PresentationProblemReportError extends V2ProblemReportError { + public problemReport: V3PresentationProblemReportMessage + + public constructor(public message: string, { problemCode, parentThreadId }: V3PresentationProblemReportErrorOptions) { + super(message, { problemCode, parentThreadId }) + this.problemReport = new V3PresentationProblemReportMessage({ + parentThreadId, + body: { + code: problemCode, + comment: message, + }, + }) + } +} diff --git a/packages/core/src/modules/proofs/protocol/v3/errors/index.ts b/packages/core/src/modules/proofs/protocol/v3/errors/index.ts new file mode 100644 index 0000000000..0db271f358 --- /dev/null +++ b/packages/core/src/modules/proofs/protocol/v3/errors/index.ts @@ -0,0 +1 @@ +export * from './V3PresentationProblemReportError' diff --git a/packages/core/src/modules/proofs/protocol/v3/handlers/V3PresentationAckHandler.ts b/packages/core/src/modules/proofs/protocol/v3/handlers/V3PresentationAckHandler.ts new file mode 100644 index 0000000000..b7e0476ee8 --- /dev/null +++ b/packages/core/src/modules/proofs/protocol/v3/handlers/V3PresentationAckHandler.ts @@ -0,0 +1,17 @@ +import type { MessageHandler, MessageHandlerInboundMessage } from '../../../../../agent/MessageHandler' +import type { V3ProofProtocol } from '../V3ProofProtocol' + +import { V3PresentationAckMessage } from '../messages' + +export class V3PresentationAckHandler implements MessageHandler { + private proofProtocol: V3ProofProtocol + public supportedMessages = [V3PresentationAckMessage] + + public constructor(proofProtocol: V3ProofProtocol) { + this.proofProtocol = proofProtocol + } + + public async handle(messageContext: MessageHandlerInboundMessage) { + await this.proofProtocol.processAck(messageContext) + } +} diff --git a/packages/core/src/modules/proofs/protocol/v3/handlers/V3PresentationHandler.ts b/packages/core/src/modules/proofs/protocol/v3/handlers/V3PresentationHandler.ts new file mode 100644 index 0000000000..9b1ebe53f1 --- /dev/null +++ b/packages/core/src/modules/proofs/protocol/v3/handlers/V3PresentationHandler.ts @@ -0,0 +1,54 @@ +import type { MessageHandler, MessageHandlerInboundMessage } from '../../../../../agent/MessageHandler' +import type { ProofExchangeRecord } from '../../../repository' +import type { V3ProofProtocol } from '../V3ProofProtocol' + +import { getOutboundMessageContext } from '../../../../../agent/getOutboundMessageContext' +import { DidCommMessageRepository } from '../../../../../storage' +import { V3PresentationMessage, V3RequestPresentationMessage } from '../messages' + +export class V3PresentationHandler implements MessageHandler { + private proofProtocol: V3ProofProtocol + public supportedMessages = [V3PresentationMessage] + + public constructor(proofProtocol: V3ProofProtocol) { + this.proofProtocol = proofProtocol + } + + public async handle(messageContext: MessageHandlerInboundMessage) { + const proofRecord = await this.proofProtocol.processPresentation(messageContext) + + const shouldAutoRespond = await this.proofProtocol.shouldAutoRespondToPresentation(messageContext.agentContext, { + proofRecord, + presentationMessage: messageContext.message, + }) + + if (shouldAutoRespond) { + return await this.acceptPresentation(proofRecord, messageContext) + } + } + + private async acceptPresentation( + proofRecord: ProofExchangeRecord, + messageContext: MessageHandlerInboundMessage + ) { + messageContext.agentContext.config.logger.info(`Automatically sending acknowledgement with autoAccept`) + + const { message } = await this.proofProtocol.acceptPresentation(messageContext.agentContext, { + proofRecord, + }) + + const didCommMessageRepository = messageContext.agentContext.dependencyManager.resolve(DidCommMessageRepository) + const requestMessage = await didCommMessageRepository.getAgentMessage(messageContext.agentContext, { + associatedRecordId: proofRecord.id, + messageClass: V3RequestPresentationMessage, + }) + + return getOutboundMessageContext(messageContext.agentContext, { + connectionRecord: messageContext.connection, + message, + associatedRecord: proofRecord, + lastReceivedMessage: messageContext.message, + lastSentMessage: requestMessage, + }) + } +} diff --git a/packages/core/src/modules/proofs/protocol/v3/handlers/V3PresentationProblemReportHandler.ts b/packages/core/src/modules/proofs/protocol/v3/handlers/V3PresentationProblemReportHandler.ts new file mode 100644 index 0000000000..4ea3835ac3 --- /dev/null +++ b/packages/core/src/modules/proofs/protocol/v3/handlers/V3PresentationProblemReportHandler.ts @@ -0,0 +1,17 @@ +import type { MessageHandler, MessageHandlerInboundMessage } from '../../../../../agent/MessageHandler' +import type { V3ProofProtocol } from '../V3ProofProtocol' + +import { V3PresentationProblemReportMessage } from '../messages' + +export class V3PresentationProblemReportHandler implements MessageHandler { + private proofService: V3ProofProtocol + public supportedMessages = [V3PresentationProblemReportMessage] + + public constructor(proofService: V3ProofProtocol) { + this.proofService = proofService + } + + public async handle(messageContext: MessageHandlerInboundMessage) { + await this.proofService.processProblemReport(messageContext) + } +} diff --git a/packages/core/src/modules/proofs/protocol/v3/handlers/V3ProposePresentationHandler.ts b/packages/core/src/modules/proofs/protocol/v3/handlers/V3ProposePresentationHandler.ts new file mode 100644 index 0000000000..c71265a3ea --- /dev/null +++ b/packages/core/src/modules/proofs/protocol/v3/handlers/V3ProposePresentationHandler.ts @@ -0,0 +1,47 @@ +import type { MessageHandler, MessageHandlerInboundMessage } from '../../../../../agent/MessageHandler' +import type { ProofExchangeRecord } from '../../../repository/ProofExchangeRecord' +import type { V3ProofProtocol } from '../V3ProofProtocol' + +import { OutboundMessageContext } from '../../../../../agent/models' +import { V3ProposePresentationMessage } from '../messages/V3ProposePresentationMessage' + +export class V3ProposePresentationHandler implements MessageHandler { + private proofProtocol: V3ProofProtocol + public supportedMessages = [V3ProposePresentationMessage] + + public constructor(proofProtocol: V3ProofProtocol) { + this.proofProtocol = proofProtocol + } + + public async handle(messageContext: MessageHandlerInboundMessage) { + const proofRecord = await this.proofProtocol.processProposal(messageContext) + + const shouldAutoRespond = await this.proofProtocol.shouldAutoRespondToProposal(messageContext.agentContext, { + proofRecord, + proposalMessage: messageContext.message, + }) + + if (shouldAutoRespond) { + return this.acceptProposal(proofRecord, messageContext) + } + } + private async acceptProposal( + proofRecord: ProofExchangeRecord, + messageContext: MessageHandlerInboundMessage + ) { + messageContext.agentContext.config.logger.info(`Automatically sending request with autoAccept`) + + if (!messageContext.connection) { + messageContext.agentContext.config.logger.error('No connection on the messageContext, aborting auto accept') + return + } + + const { message } = await this.proofProtocol.acceptProposal(messageContext.agentContext, { proofRecord }) + + return new OutboundMessageContext(message, { + agentContext: messageContext.agentContext, + connection: messageContext.connection, + associatedRecord: proofRecord, + }) + } +} diff --git a/packages/core/src/modules/proofs/protocol/v3/handlers/V3RequestPresentationHandler.ts b/packages/core/src/modules/proofs/protocol/v3/handlers/V3RequestPresentationHandler.ts new file mode 100644 index 0000000000..59a2ce8640 --- /dev/null +++ b/packages/core/src/modules/proofs/protocol/v3/handlers/V3RequestPresentationHandler.ts @@ -0,0 +1,48 @@ +import type { MessageHandler, MessageHandlerInboundMessage } from '../../../../../agent/MessageHandler' +import type { ProofExchangeRecord } from '../../../repository/ProofExchangeRecord' +import type { V3ProofProtocol } from '../V3ProofProtocol' + +import { getOutboundMessageContext } from '../../../../../agent/getOutboundMessageContext' +import { V3RequestPresentationMessage } from '../messages/V3RequestPresentationMessage' + +export class V3RequestPresentationHandler implements MessageHandler { + private proofProtocol: V3ProofProtocol + public supportedMessages = [V3RequestPresentationMessage] + + public constructor(proofProtocol: V3ProofProtocol) { + this.proofProtocol = proofProtocol + } + + public async handle(messageContext: MessageHandlerInboundMessage) { + const proofRecord = await this.proofProtocol.processRequest(messageContext) + + const shouldAutoRespond = await this.proofProtocol.shouldAutoRespondToRequest(messageContext.agentContext, { + proofRecord, + requestMessage: messageContext.message, + }) + + messageContext.agentContext.config.logger.debug(`Should auto respond to request: ${shouldAutoRespond}`) + + if (shouldAutoRespond) { + return await this.acceptRequest(proofRecord, messageContext) + } + } + + private async acceptRequest( + proofRecord: ProofExchangeRecord, + messageContext: MessageHandlerInboundMessage + ) { + messageContext.agentContext.config.logger.info(`Automatically sending presentation with autoAccept`) + + const { message } = await this.proofProtocol.acceptRequest(messageContext.agentContext, { + proofRecord, + }) + + return getOutboundMessageContext(messageContext.agentContext, { + message, + lastReceivedMessage: messageContext.message, + associatedRecord: proofRecord, + connectionRecord: messageContext.connection, + }) + } +} diff --git a/packages/core/src/modules/proofs/protocol/v3/index.ts b/packages/core/src/modules/proofs/protocol/v3/index.ts new file mode 100644 index 0000000000..c0aebb1bf7 --- /dev/null +++ b/packages/core/src/modules/proofs/protocol/v3/index.ts @@ -0,0 +1,3 @@ +export * from './errors' +export * from './messages' +export * from './V3ProofProtocol' diff --git a/packages/core/src/modules/proofs/protocol/v3/messages/V3PresentationAckMessage.ts b/packages/core/src/modules/proofs/protocol/v3/messages/V3PresentationAckMessage.ts new file mode 100644 index 0000000000..527dff1b66 --- /dev/null +++ b/packages/core/src/modules/proofs/protocol/v3/messages/V3PresentationAckMessage.ts @@ -0,0 +1,25 @@ +import { DidCommV2Message } from '../../../../../didcomm' +import { IsValidMessageType, parseMessageType } from '../../../../../utils/messageType' + +export type V3PresentationAckMessageOptions = { + id?: string + threadId: string +} + +export class V3PresentationAckMessage extends DidCommV2Message { + /** + * Create new V3PresentationAckMessage instance. + * @param options + */ + public constructor(options: V3PresentationAckMessageOptions) { + super() + if (options) { + this.id = options.id ?? this.generateId() + this.thid = options.threadId + } + } + + @IsValidMessageType(V3PresentationAckMessage.type) + public readonly type = V3PresentationAckMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/present-proof/3.0/ack') +} diff --git a/packages/core/src/modules/proofs/protocol/v3/messages/V3PresentationMessage.ts b/packages/core/src/modules/proofs/protocol/v3/messages/V3PresentationMessage.ts new file mode 100644 index 0000000000..14f5f654d0 --- /dev/null +++ b/packages/core/src/modules/proofs/protocol/v3/messages/V3PresentationMessage.ts @@ -0,0 +1,60 @@ +import { Expose, Type } from 'class-transformer' +import { IsArray, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator' + +import { V2Attachment } from '../../../../../decorators/attachment' +import { DidCommV2Message } from '../../../../../didcomm' +import { IsValidMessageType, parseMessageType } from '../../../../../utils/messageType' +import { uuid } from '../../../../../utils/uuid' + +export interface V3PresentationMessageOptions { + id?: string + goalCode?: string + comment?: string + attachments: V2Attachment[] +} + +class V3PresentationMessageBody { + public constructor(options: { goalCode?: string; comment?: string }) { + if (options) { + this.comment = options.comment + this.goalCode = options.goalCode + } + } + + @IsString() + @IsOptional() + public comment?: string + + @Expose({ name: 'goal_code' }) + @IsString() + @IsOptional() + public goalCode?: string +} + +export class V3PresentationMessage extends DidCommV2Message { + public constructor(options: V3PresentationMessageOptions) { + super() + + if (options) { + this.id = options.id ?? uuid() + this.body = new V3PresentationMessageBody(options) + this.attachments = options.attachments + } + } + + @IsValidMessageType(V3PresentationMessage.type) + public readonly type = V3PresentationMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/present-proof/3.0/presentation') + + @IsObject() + @ValidateNested() + @Type(() => V3PresentationMessageBody) + public body!: V3PresentationMessageBody + + @Type(() => V2Attachment) + @IsArray() + @ValidateNested({ + each: true, + }) + public attachments!: Array +} diff --git a/packages/core/src/modules/proofs/protocol/v3/messages/V3PresentationProblemReportMessage.ts b/packages/core/src/modules/proofs/protocol/v3/messages/V3PresentationProblemReportMessage.ts new file mode 100644 index 0000000000..911fb05d22 --- /dev/null +++ b/packages/core/src/modules/proofs/protocol/v3/messages/V3PresentationProblemReportMessage.ts @@ -0,0 +1,23 @@ +import type { V2ProblemReportMessageOptions } from '../../../../problem-reports/versions/v2/messages' + +import { IsValidMessageType, parseMessageType } from '../../../../../utils/messageType' +import { V2ProblemReportMessage } from '../../../../problem-reports/versions/v2' + +export type V3PresentationProblemReportMessageOptions = V2ProblemReportMessageOptions + +/** + * @see https://github.com/hyperledger/aries-rfcs/blob/main/features/0035-report-problem/README.md + */ +export class V3PresentationProblemReportMessage extends V2ProblemReportMessage { + /** + * Create new V3PresentationProblemReportMessage instance. + * @param options + */ + public constructor(options: V3PresentationProblemReportMessageOptions) { + super(options) + } + + @IsValidMessageType(V3PresentationProblemReportMessage.type) + public readonly type = V3PresentationProblemReportMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/present-proof/3.0/problem-report') +} diff --git a/packages/core/src/modules/proofs/protocol/v3/messages/V3ProposePresentationMessage.ts b/packages/core/src/modules/proofs/protocol/v3/messages/V3ProposePresentationMessage.ts new file mode 100644 index 0000000000..61f7cb511e --- /dev/null +++ b/packages/core/src/modules/proofs/protocol/v3/messages/V3ProposePresentationMessage.ts @@ -0,0 +1,60 @@ +import { Expose, Type } from 'class-transformer' +import { IsArray, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator' + +import { V2Attachment } from '../../../../../decorators/attachment' +import { DidCommV2Message } from '../../../../../didcomm' +import { IsValidMessageType, parseMessageType } from '../../../../../utils/messageType' +import { uuid } from '../../../../../utils/uuid' + +export interface V3ProposePresentationMessageOptions { + id?: string + comment?: string + goalCode?: string + attachments: V2Attachment[] +} + +class V3ProposePresentationMessageBody { + public constructor(options: { goalCode?: string; comment?: string }) { + if (options) { + this.comment = options.comment + this.goalCode = options.goalCode + } + } + + @IsString() + @IsOptional() + public comment?: string + + @Expose({ name: 'goal_code' }) + @IsString() + @IsOptional() + public goalCode?: string +} + +export class V3ProposePresentationMessage extends DidCommV2Message { + public constructor(options: V3ProposePresentationMessageOptions) { + super() + + if (options) { + this.id = options.id ?? uuid() + this.body = new V3ProposePresentationMessageBody(options) + this.attachments = options.attachments + } + } + + @IsValidMessageType(V3ProposePresentationMessage.type) + public readonly type = V3ProposePresentationMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/present-proof/3.0/propose-presentation') + + @IsObject() + @ValidateNested() + @Type(() => V3ProposePresentationMessageBody) + public body!: V3ProposePresentationMessageBody + + @Type(() => V2Attachment) + @IsArray() + @ValidateNested({ + each: true, + }) + public attachments!: Array +} diff --git a/packages/core/src/modules/proofs/protocol/v3/messages/V3RequestPresentationMessage.ts b/packages/core/src/modules/proofs/protocol/v3/messages/V3RequestPresentationMessage.ts new file mode 100644 index 0000000000..493c66b82d --- /dev/null +++ b/packages/core/src/modules/proofs/protocol/v3/messages/V3RequestPresentationMessage.ts @@ -0,0 +1,66 @@ +import { Expose, Type } from 'class-transformer' +import { IsArray, IsBoolean, IsObject, IsOptional, IsString, ValidateNested } from 'class-validator' + +import { V2Attachment } from '../../../../../decorators/attachment' +import { DidCommV2Message } from '../../../../../didcomm' +import { IsValidMessageType, parseMessageType } from '../../../../../utils/messageType' +import { uuid } from '../../../../../utils/uuid' + +export interface V3RequestPresentationMessageOptions { + id?: string + comment?: string + goalCode?: string + willConfirm?: boolean + attachments: V2Attachment[] +} + +class V3RequestPresentationMessageBody { + public constructor(options: { goalCode?: string; comment?: string; willConfirm?: boolean }) { + if (options) { + this.comment = options.comment + this.goalCode = options.goalCode + this.willConfirm = options.willConfirm ?? true + } + } + + @IsString() + @IsOptional() + public comment?: string + + @Expose({ name: 'goal_code' }) + @IsString() + @IsOptional() + public goalCode?: string + + @Expose({ name: 'will_confirm' }) + @IsBoolean() + public willConfirm!: boolean +} + +export class V3RequestPresentationMessage extends DidCommV2Message { + public constructor(options: V3RequestPresentationMessageOptions) { + super() + + if (options) { + this.id = options.id ?? uuid() + this.body = new V3RequestPresentationMessageBody(options) + this.attachments = options.attachments + } + } + + @IsValidMessageType(V3RequestPresentationMessage.type) + public readonly type = V3RequestPresentationMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/present-proof/3.0/request-presentation') + + @IsObject() + @ValidateNested() + @Type(() => V3RequestPresentationMessageBody) + public body!: V3RequestPresentationMessageBody + + @Type(() => V2Attachment) + @IsArray() + @ValidateNested({ + each: true, + }) + public attachments!: Array +} diff --git a/packages/core/src/modules/proofs/protocol/v3/messages/index.ts b/packages/core/src/modules/proofs/protocol/v3/messages/index.ts new file mode 100644 index 0000000000..be3579f324 --- /dev/null +++ b/packages/core/src/modules/proofs/protocol/v3/messages/index.ts @@ -0,0 +1,5 @@ +export * from './V3PresentationAckMessage' +export * from './V3PresentationMessage' +export * from './V3PresentationProblemReportMessage' +export * from './V3ProposePresentationMessage' +export * from './V3RequestPresentationMessage' diff --git a/packages/core/src/storage/didcomm/DidCommMessageRecord.ts b/packages/core/src/storage/didcomm/DidCommMessageRecord.ts index 6e5a60b3b0..7d0a88c555 100644 --- a/packages/core/src/storage/didcomm/DidCommMessageRecord.ts +++ b/packages/core/src/storage/didcomm/DidCommMessageRecord.ts @@ -58,13 +58,13 @@ export class DidCommMessageRecord extends BaseRecord } public getTags() { - const messageId = this.message['@id'] as string - const messageType = this.message['@type'] as string + const messageId = (this.message['@id'] ?? this.message['id']) as string + const messageType = (this.message['@type'] ?? this.message['type']) as string const { protocolName, protocolMajorVersion, protocolMinorVersion, messageName } = parseMessageType(messageType) const thread = this.message['~thread'] - let threadId = messageId + let threadId = (this.message['thid'] ?? messageId) as string if (isJsonObject(thread) && typeof thread.thid === 'string') { threadId = thread.thid @@ -89,7 +89,7 @@ export class DidCommMessageRecord extends BaseRecord public getMessageInstance( messageClass: MessageClass ): InstanceType { - const messageType = parseMessageType(this.message['@type'] as string) + const messageType = parseMessageType((this.message['@type'] ?? this.message['type']) as string) if (!canHandleMessageType(messageClass, messageType)) { throw new AriesFrameworkError('Provided message class type does not match type of stored message') diff --git a/packages/core/tests/helpers.ts b/packages/core/tests/helpers.ts index 4fe9732007..a907165bd9 100644 --- a/packages/core/tests/helpers.ts +++ b/packages/core/tests/helpers.ts @@ -2,8 +2,6 @@ import type { AgentDependencies, BaseEvent, - BasicMessage, - BasicMessageStateChangedEvent, ConnectionRecordProps, CredentialStateChangedEvent, InitConfig, @@ -14,6 +12,7 @@ import type { CredentialState, ConnectionStateChangedEvent, Buffer, + AgentMessageProcessedEvent, } from '../src' import type { AgentModulesInput, EmptyModuleMap } from '../src/agent/AgentModules' import type { @@ -31,13 +30,15 @@ import { catchError, filter, map, take, timeout } from 'rxjs/operators' import { agentDependencies, IndySdkPostgresWalletScheme } from '../../node/src' import { + V1BasicMessage, + V2BasicMessage, + OutOfBandVersion, OutOfBandDidCommService, ConnectionsModule, ConnectionEventTypes, TypedArrayEncoder, AgentConfig, AgentContext, - BasicMessageEventTypes, ConnectionRecord, CredentialEventTypes, DependencyManager, @@ -47,6 +48,7 @@ import { InjectionSymbols, ProofEventTypes, TrustPingEventTypes, + AgentEventTypes, } from '../src' import { Key, KeyType } from '../src/crypto' import { DidKey } from '../src/modules/dids/methods/key' @@ -217,9 +219,11 @@ const isCredentialStateChangedEvent = (e: BaseEvent): e is CredentialStateChange const isConnectionStateChangedEvent = (e: BaseEvent): e is ConnectionStateChangedEvent => e.type === ConnectionEventTypes.ConnectionStateChanged const isTrustPingReceivedEvent = (e: BaseEvent): e is TrustPingReceivedEvent => - e.type === TrustPingEventTypes.TrustPingReceivedEvent + e.type === TrustPingEventTypes.TrustPingReceivedEvent || e.type === TrustPingEventTypes.V2TrustPingReceivedEvent const isTrustPingResponseReceivedEvent = (e: BaseEvent): e is TrustPingResponseReceivedEvent => e.type === TrustPingEventTypes.TrustPingResponseReceivedEvent +const isAgentMessageProcessedEvent = (e: BaseEvent): e is AgentMessageProcessedEvent => + e.type === AgentEventTypes.AgentMessageProcessed export function waitForProofExchangeRecordSubject( subject: ReplaySubject | Observable, @@ -267,11 +271,16 @@ export function waitForProofExchangeRecordSubject( export async function waitForTrustPingReceivedEvent( agent: Agent, options: { + protocolVersion?: 'v1' | 'v2' threadId?: string timeoutMs?: number } ) { - const observable = agent.events.observable(TrustPingEventTypes.TrustPingReceivedEvent) + const observable = agent.events.observable( + options.protocolVersion === 'v2' + ? TrustPingEventTypes.V2TrustPingReceivedEvent + : TrustPingEventTypes.TrustPingReceivedEvent + ) return waitForTrustPingReceivedEventSubject(observable, options) } @@ -442,20 +451,40 @@ export async function waitForConnectionRecord( return waitForConnectionRecordSubject(observable, options) } -export async function waitForBasicMessage(agent: Agent, { content }: { content?: string }): Promise { - return new Promise((resolve) => { - const listener = (event: BasicMessageStateChangedEvent) => { - const contentMatches = content === undefined || event.payload.message.content === content - - if (contentMatches) { - agent.events.off(BasicMessageEventTypes.BasicMessageStateChanged, listener) +export async function waitForBasicMessage( + agent: Agent, + { content, timeoutMs }: { content?: string; timeoutMs?: number } +): Promise { + const observable = agent.events.observable(AgentEventTypes.AgentMessageProcessed) + return waitForBasicMessageSubject(observable, { content, timeoutMs }) +} - resolve(event.payload.message) - } - } +export function waitForBasicMessageSubject( + subject: ReplaySubject | Observable, + { + content, + timeoutMs = 5000, + }: { + content?: string + timeoutMs?: number + } +) { + const observable = subject instanceof ReplaySubject ? subject.asObservable() : subject - agent.events.on(BasicMessageEventTypes.BasicMessageStateChanged, listener) - }) + return firstValueFrom( + observable.pipe( + filter(isAgentMessageProcessedEvent), + map((e) => e.payload.message), + filter((e): e is V1BasicMessage | V2BasicMessage => + [V1BasicMessage.type.messageTypeUri, V2BasicMessage.type.messageTypeUri].includes(e.type) + ), + filter((e) => content === undefined || e.content === content), + timeout(timeoutMs), + catchError(() => { + throw new Error(`Basic Message not received within specified timeout: { content: ${content} }`) + }) + ) + ) } export function getMockConnection({ @@ -532,20 +561,36 @@ export function getMockOutOfBand({ return outOfBandRecord } -export async function makeConnection(agentA: Agent, agentB: Agent) { - const agentAOutOfBand = await agentA.oob.createInvitation({ - handshakeProtocols: [HandshakeProtocol.Connections], - }) +export async function makeConnection(agentA: Agent, agentB: Agent, version?: OutOfBandVersion) { + if (version === OutOfBandVersion.V2) { + const agentAOutOfBand = await agentA.oob.createInvitation({ + version, + }) - let { connectionRecord: agentBConnection } = await agentB.oob.receiveInvitation( - agentAOutOfBand.getOutOfBandInvitation() - ) + const { connectionRecord: agentBConnection } = await agentB.oob.receiveInvitation( + agentAOutOfBand.v2OutOfBandInvitation! + ) + if (!agentBConnection) throw new Error('No connection for receiver') + await agentB.connections.sendPing(agentBConnection.id, {}) + await waitForTrustPingReceivedEvent(agentA, { protocolVersion: 'v2', timeoutMs: 4000 }) + const [agentAConnection] = await agentA.connections.findAllByOutOfBandId(agentAOutOfBand.id) + if (!agentAConnection) throw new Error('No connection for inviter') + return [agentAConnection, agentBConnection] + } else { + const agentAOutOfBand = await agentA.oob.createInvitation({ + handshakeProtocols: [HandshakeProtocol.Connections], + }) + + let { connectionRecord: agentBConnection } = await agentB.oob.receiveInvitation( + agentAOutOfBand.getOutOfBandInvitation() + ) - agentBConnection = await agentB.connections.returnWhenIsConnected(agentBConnection!.id) - let [agentAConnection] = await agentA.connections.findAllByOutOfBandId(agentAOutOfBand.id) - agentAConnection = await agentA.connections.returnWhenIsConnected(agentAConnection!.id) + agentBConnection = await agentB.connections.returnWhenIsConnected(agentBConnection!.id) + let [agentAConnection] = await agentA.connections.findAllByOutOfBandId(agentAOutOfBand.id) + agentAConnection = await agentA.connections.returnWhenIsConnected(agentAConnection!.id) - return [agentAConnection, agentBConnection] + return [agentAConnection, agentBConnection] + } } /**