diff --git a/packages/anoncreds/src/protocols/credentials/v1/V1CredentialProtocol.ts b/packages/anoncreds/src/protocols/credentials/v1/V1CredentialProtocol.ts index 775c47aff5..67d7b3886e 100644 --- a/packages/anoncreds/src/protocols/credentials/v1/V1CredentialProtocol.ts +++ b/packages/anoncreds/src/protocols/credentials/v1/V1CredentialProtocol.ts @@ -327,7 +327,7 @@ export class V1CredentialProtocol attachments: credentialRecord.linkedAttachments, }) - message.setThread({ threadId: credentialRecord.threadId }) + message.setThread({ threadId: credentialRecord.threadId, parentThreadId: credentialRecord.parentThreadId }) credentialRecord.credentialAttributes = message.credentialPreview.attributes credentialRecord.autoAcceptCredential = autoAcceptCredential ?? credentialRecord.autoAcceptCredential @@ -384,7 +384,7 @@ export class V1CredentialProtocol }), attachments: credentialRecord.linkedAttachments, }) - message.setThread({ threadId: credentialRecord.threadId }) + message.setThread({ threadId: credentialRecord.threadId, parentThreadId: credentialRecord.parentThreadId }) credentialRecord.credentialAttributes = message.credentialPreview.attributes credentialRecord.autoAcceptCredential = autoAcceptCredential ?? credentialRecord.autoAcceptCredential @@ -541,6 +541,7 @@ export class V1CredentialProtocol credentialRecord = new CredentialExchangeRecord({ connectionId: connection?.id, threadId: offerMessage.threadId, + parentThreadId: offerMessage.thread?.parentThreadId, state: CredentialState.OfferReceived, protocolVersion: 'v1', }) @@ -612,7 +613,7 @@ export class V1CredentialProtocol requestAttachments: [attachment], attachments: offerMessage.appendedAttachments?.filter((attachment) => isLinkedAttachment(attachment)), }) - requestMessage.setThread({ threadId: credentialRecord.threadId }) + requestMessage.setThread({ threadId: credentialRecord.threadId, parentThreadId: credentialRecord.parentThreadId }) credentialRecord.credentialAttributes = offerMessage.credentialPreview.attributes credentialRecord.autoAcceptCredential = autoAcceptCredential ?? credentialRecord.autoAcceptCredential @@ -691,7 +692,7 @@ export class V1CredentialProtocol comment, }) - message.setThread({ threadId: credentialRecord.threadId }) + message.setThread({ threadId: credentialRecord.threadId, parentThreadId: credentialRecord.parentThreadId }) await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { agentMessage: message, @@ -731,11 +732,7 @@ export class V1CredentialProtocol agentContext.config.logger.debug(`Processing credential request with id ${requestMessage.id}`) - const credentialRecord = await this.getByThreadAndConnectionId( - messageContext.agentContext, - requestMessage.threadId, - connection?.id - ) + const credentialRecord = await this.getByThreadAndConnectionId(messageContext.agentContext, requestMessage.threadId) agentContext.config.logger.trace('Credential record found when processing credential request', credentialRecord) const proposalMessage = await didCommMessageRepository.findAgentMessage(messageContext.agentContext, { @@ -755,6 +752,15 @@ export class V1CredentialProtocol lastSentMessage: offerMessage ?? undefined, }) + // This makes sure that the sender of the incoming message is authorized to do so. + if (!credentialRecord.connectionId) { + await connectionService.matchIncomingMessageToRequestMessageInOutOfBandExchange(messageContext, { + expectedConnectionId: credentialRecord.connectionId, + }) + + credentialRecord.connectionId = connection?.id + } + const requestAttachment = requestMessage.getRequestAttachmentById(INDY_CREDENTIAL_REQUEST_ATTACHMENT_ID) if (!requestAttachment) { @@ -833,7 +839,7 @@ export class V1CredentialProtocol attachments: credentialRecord.linkedAttachments, }) - issueMessage.setThread({ threadId: credentialRecord.threadId }) + issueMessage.setThread({ threadId: credentialRecord.threadId, parentThreadId: credentialRecord.parentThreadId }) issueMessage.setPleaseAck() await didCommMessageRepository.saveAgentMessage(agentContext, { @@ -938,6 +944,8 @@ export class V1CredentialProtocol threadId: credentialRecord.threadId, }) + ackMessage.setThread({ threadId: credentialRecord.threadId, parentThreadId: credentialRecord.parentThreadId }) + await this.updateState(agentContext, credentialRecord, CredentialState.Done) return { message: ackMessage, credentialRecord } diff --git a/packages/anoncreds/src/protocols/credentials/v1/__tests__/V1CredentialProtocolCred.test.ts b/packages/anoncreds/src/protocols/credentials/v1/__tests__/V1CredentialProtocolCred.test.ts index 0abcbba515..d745c55a22 100644 --- a/packages/anoncreds/src/protocols/credentials/v1/__tests__/V1CredentialProtocolCred.test.ts +++ b/packages/anoncreds/src/protocols/credentials/v1/__tests__/V1CredentialProtocolCred.test.ts @@ -343,7 +343,6 @@ describe('V1CredentialProtocol', () => { // then expect(credentialRepository.getSingleByQuery).toHaveBeenNthCalledWith(1, agentContext, { threadId: 'somethreadid', - connectionId: connection.id, }) expect(repositoryUpdateSpy).toHaveBeenCalledTimes(1) expect(returnedCredentialRecord.state).toEqual(CredentialState.RequestReceived) @@ -360,7 +359,6 @@ describe('V1CredentialProtocol', () => { // then expect(credentialRepository.getSingleByQuery).toHaveBeenNthCalledWith(1, agentContext, { threadId: 'somethreadid', - connectionId: connection.id, }) expect(returnedCredentialRecord.state).toEqual(CredentialState.RequestReceived) }) diff --git a/packages/anoncreds/src/protocols/proofs/v1/V1ProofProtocol.ts b/packages/anoncreds/src/protocols/proofs/v1/V1ProofProtocol.ts index 606dc6e7ce..4a8df7c6e0 100644 --- a/packages/anoncreds/src/protocols/proofs/v1/V1ProofProtocol.ts +++ b/packages/anoncreds/src/protocols/proofs/v1/V1ProofProtocol.ts @@ -326,6 +326,7 @@ export class V1ProofProtocol extends BaseProofProtocol implements ProofProtocol< }) requestPresentationMessage.setThread({ threadId: proofRecord.threadId, + parentThreadId: proofRecord.parentThreadId, }) await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { @@ -523,7 +524,7 @@ export class V1ProofProtocol extends BaseProofProtocol implements ProofProtocol< comment, presentationProposal, }) - message.setThread({ threadId: proofRecord.threadId }) + message.setThread({ threadId: proofRecord.threadId, parentThreadId: proofRecord.parentThreadId }) await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { agentMessage: message, @@ -600,7 +601,7 @@ export class V1ProofProtocol extends BaseProofProtocol implements ProofProtocol< comment, presentationAttachments: [attachment], }) - message.setThread({ threadId: proofRecord.threadId }) + message.setThread({ threadId: proofRecord.threadId, parentThreadId: proofRecord.parentThreadId }) await didCommMessageRepository.saveAgentMessage(agentContext, { agentMessage: message, @@ -745,11 +746,7 @@ export class V1ProofProtocol extends BaseProofProtocol implements ProofProtocol< // only depends on the public api, rather than the internal API (this helps with breaking changes) const connectionService = agentContext.dependencyManager.resolve(ConnectionService) - const proofRecord = await this.getByThreadAndConnectionId( - agentContext, - presentationMessage.threadId, - connection?.id - ) + const proofRecord = await this.getByThreadAndConnectionId(agentContext, presentationMessage.threadId) const proposalMessage = await didCommMessageRepository.findAgentMessage(agentContext, { associatedRecordId: proofRecord.id, @@ -769,6 +766,15 @@ export class V1ProofProtocol extends BaseProofProtocol implements ProofProtocol< lastSentMessage: requestMessage, }) + // This makes sure that the sender of the incoming message is authorized to do so. + if (!proofRecord.connectionId) { + await connectionService.matchIncomingMessageToRequestMessageInOutOfBandExchange(messageContext, { + expectedConnectionId: proofRecord.connectionId, + }) + + proofRecord.connectionId = connection?.id + } + const presentationAttachment = presentationMessage.getPresentationAttachmentById(INDY_PROOF_ATTACHMENT_ID) if (!presentationAttachment) { throw new AriesFrameworkError('Missing indy proof attachment in processPresentation') @@ -814,6 +820,11 @@ export class V1ProofProtocol extends BaseProofProtocol implements ProofProtocol< threadId: proofRecord.threadId, }) + ackMessage.setThread({ + threadId: proofRecord.threadId, + parentThreadId: proofRecord.parentThreadId, + }) + // Update record await this.updateState(agentContext, proofRecord, ProofState.Done) diff --git a/packages/anoncreds/src/updates/__tests__/__snapshots__/0.3.test.ts.snap b/packages/anoncreds/src/updates/__tests__/__snapshots__/0.3.test.ts.snap index 1aacd46acc..d6061e5889 100644 --- a/packages/anoncreds/src/updates/__tests__/__snapshots__/0.3.test.ts.snap +++ b/packages/anoncreds/src/updates/__tests__/__snapshots__/0.3.test.ts.snap @@ -27,6 +27,7 @@ exports[`UpdateAssistant | AnonCreds | v0.3.1 - v0.4 should correctly update the "credentialIds": [ "f54d231b-ef4f-4da5-adad-b10a1edaeb18", ], + "parentThreadId": undefined, "state": "done", "threadId": "c5fc78be-b355-4411-86f3-3d97482b9841", }, @@ -209,6 +210,7 @@ exports[`UpdateAssistant | AnonCreds | v0.3.1 - v0.4 should correctly update the "tags": { "connectionId": undefined, "credentialIds": [], + "parentThreadId": undefined, "state": "offer-received", "threadId": "f9f79a46-a4d8-4ee7-9745-1b9cdf03676b", }, @@ -511,6 +513,7 @@ exports[`UpdateAssistant | AnonCreds | v0.3.1 - v0.4 should correctly update the "tags": { "connectionId": undefined, "credentialIds": [], + "parentThreadId": undefined, "state": "offer-sent", "threadId": "f9f79a46-a4d8-4ee7-9745-1b9cdf03676b", }, @@ -604,6 +607,7 @@ exports[`UpdateAssistant | AnonCreds | v0.3.1 - v0.4 should correctly update the "tags": { "connectionId": undefined, "credentialIds": [], + "parentThreadId": undefined, "state": "done", "threadId": "c5fc78be-b355-4411-86f3-3d97482b9841", }, diff --git a/packages/core/src/agent/getOutboundMessageContext.ts b/packages/core/src/agent/getOutboundMessageContext.ts index 068ce8aafb..ee39cbf43c 100644 --- a/packages/core/src/agent/getOutboundMessageContext.ts +++ b/packages/core/src/agent/getOutboundMessageContext.ts @@ -5,12 +5,10 @@ import type { ResolvedDidCommService } from '../modules/didcomm' import type { OutOfBandRecord } from '../modules/oob' import type { BaseRecordAny } from '../storage/BaseRecord' -import { Agent } from 'http' - import { Key } from '../crypto' import { ServiceDecorator } from '../decorators/service/ServiceDecorator' import { AriesFrameworkError } from '../error' -import { OutOfBandService, OutOfBandRole, OutOfBandRepository } from '../modules/oob' +import { InvitationType, OutOfBandRepository, OutOfBandRole, OutOfBandService } from '../modules/oob' import { OutOfBandRecordMetadataKeys } from '../modules/oob/repository/outOfBandRecordMetadataTypes' import { RoutingService } from '../modules/routing' import { DidCommMessageRepository, DidCommMessageRole } from '../storage' @@ -297,8 +295,11 @@ async function addExchangeDataToMessage( associatedRecord: BaseRecordAny } ) { + const legacyInvitationMetadata = outOfBandRecord?.metadata.get(OutOfBandRecordMetadataKeys.LegacyInvitation) + // Set the parentThreadId on the message from the oob invitation - if (outOfBandRecord) { + // If connectionless is used, we should not add the parentThreadId + if (outOfBandRecord && legacyInvitationMetadata?.legacyInvitationType !== InvitationType.Connectionless) { if (!message.thread) { message.setThread({ parentThreadId: outOfBandRecord.outOfBandInvitation.id, diff --git a/packages/core/src/modules/connections/services/ConnectionService.ts b/packages/core/src/modules/connections/services/ConnectionService.ts index 059c785452..ff36bd5b05 100644 --- a/packages/core/src/modules/connections/services/ConnectionService.ts +++ b/packages/core/src/modules/connections/services/ConnectionService.ts @@ -33,6 +33,7 @@ import { OutOfBandService } from '../../oob/OutOfBandService' import { OutOfBandRole } from '../../oob/domain/OutOfBandRole' import { OutOfBandState } from '../../oob/domain/OutOfBandState' import { OutOfBandRepository } from '../../oob/repository' +import { OutOfBandRecordMetadataKeys } from '../../oob/repository/outOfBandRecordMetadataTypes' import { ConnectionEventTypes } from '../ConnectionEvents' import { ConnectionProblemReportError, ConnectionProblemReportReason } from '../errors' import { ConnectionRequestMessage, ConnectionResponseMessage, TrustPingMessage } from '../messages' @@ -538,6 +539,86 @@ export class ConnectionService { } } + /** + * If knownConnectionId is passed, it will compare the incoming connection id with the knownConnectionId, and skip the other validation. + * + * If no known connection id is passed, it asserts that the incoming message is in response to an attached request message to an out of band invitation. + * If is the case, and the state of the out of band record is still await response, the state will be updated to done + * + */ + public async matchIncomingMessageToRequestMessageInOutOfBandExchange( + messageContext: InboundMessageContext, + { expectedConnectionId }: { expectedConnectionId?: string } + ) { + if (expectedConnectionId && messageContext.connection?.id === expectedConnectionId) { + throw new AriesFrameworkError( + `Expecting incoming message to have connection ${expectedConnectionId}, but incoming connection is ${ + messageContext.connection?.id ?? 'undefined' + }` + ) + } + + const outOfBandRepository = messageContext.agentContext.dependencyManager.resolve(OutOfBandRepository) + const outOfBandInvitationId = messageContext.message.thread?.parentThreadId + + // Find the out of band record that is associated with this request + const outOfBandRecord = await outOfBandRepository.findSingleByQuery(messageContext.agentContext, { + invitationId: outOfBandInvitationId, + role: OutOfBandRole.Sender, + invitationRequestsThreadIds: [messageContext.message.threadId], + }) + + // There is no out of band record + if (!outOfBandRecord) { + throw new AriesFrameworkError( + `No out of band record found for credential request message with thread ${messageContext.message.threadId}, out of band invitation id ${outOfBandInvitationId} and role ${OutOfBandRole.Sender}` + ) + } + + const legacyInvitationMetadata = outOfBandRecord.metadata.get(OutOfBandRecordMetadataKeys.LegacyInvitation) + + // If the original invitation was a legacy connectionless invitation, it's okay if the message does not have a pthid. + if ( + legacyInvitationMetadata?.legacyInvitationType !== 'connectionless' && + outOfBandRecord.outOfBandInvitation.id !== outOfBandInvitationId + ) { + throw new AriesFrameworkError( + 'Response messages to out of band invitation requests MUST have a parent thread id that matches the out of band invitation id.' + ) + } + + // This should not happen, as it is not allowed to create reusable out of band invitations with attached messages + // But should that implementation change, we at least cover it here. + if (outOfBandRecord.reusable) { + throw new AriesFrameworkError( + 'Receiving messages in response to reusable out of band invitations is not supported.' + ) + } + + if (outOfBandRecord.state === OutOfBandState.Done) { + if (!messageContext.connection) { + throw new AriesFrameworkError( + "Can't find connection associated with incoming message, while out of band state is done. State must be await response if no connection has been created" + ) + } + if (messageContext.connection.outOfBandId !== outOfBandRecord.id) { + throw new AriesFrameworkError( + 'Connection associated with incoming message is not associated with the out of band invitation containing the attached message.' + ) + } + + // We're good to go. Connection was created and points to the correct out of band record. And the message is in response to an attached request message from the oob invitation. + } else if (outOfBandRecord.state === OutOfBandState.AwaitResponse) { + // We're good to go. Waiting for a response. And the message is in response to an attached request message from the oob invitation. + + // Now that we have received the first response message to our out of band invitation, we mark the out of band record as done + outOfBandRecord.state = OutOfBandState.Done + await outOfBandRepository.update(messageContext.agentContext, outOfBandRecord) + } else { + throw new AriesFrameworkError(`Out of band record is in incorrect state ${outOfBandRecord.state}`) + } + } + public async updateState(agentContext: AgentContext, connectionRecord: ConnectionRecord, newState: DidExchangeState) { const previousState = connectionRecord.state connectionRecord.state = newState diff --git a/packages/core/src/modules/credentials/CredentialsApi.ts b/packages/core/src/modules/credentials/CredentialsApi.ts index c8eba61ae9..4a0a6ac349 100644 --- a/packages/core/src/modules/credentials/CredentialsApi.ts +++ b/packages/core/src/modules/credentials/CredentialsApi.ts @@ -505,6 +505,7 @@ export class CredentialsApi implements Credent }) message.setThread({ threadId: credentialRecord.threadId, + parentThreadId: credentialRecord.parentThreadId, }) const outboundMessageContext = await getOutboundMessageContext(this.agentContext, { message, diff --git a/packages/core/src/modules/credentials/protocol/v2/CredentialFormatCoordinator.ts b/packages/core/src/modules/credentials/protocol/v2/CredentialFormatCoordinator.ts index 1def0ac9f4..c8fe64e86d 100644 --- a/packages/core/src/modules/credentials/protocol/v2/CredentialFormatCoordinator.ts +++ b/packages/core/src/modules/credentials/protocol/v2/CredentialFormatCoordinator.ts @@ -70,7 +70,7 @@ export class CredentialFormatCoordinator credentialPreview, }) - message.setThread({ threadId: credentialRecord.threadId }) + message.setThread({ threadId: credentialRecord.threadId, parentThreadId: credentialRecord.parentThreadId }) await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { agentMessage: message, @@ -182,7 +182,7 @@ export class CredentialFormatCoordinator comment, }) - message.setThread({ threadId: credentialRecord.threadId }) + message.setThread({ threadId: credentialRecord.threadId, parentThreadId: credentialRecord.parentThreadId }) await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { agentMessage: message, @@ -254,7 +254,7 @@ export class CredentialFormatCoordinator credentialPreview, }) - message.setThread({ threadId: credentialRecord.threadId }) + message.setThread({ threadId: credentialRecord.threadId, parentThreadId: credentialRecord.parentThreadId }) await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { agentMessage: message, @@ -345,7 +345,7 @@ export class CredentialFormatCoordinator comment, }) - message.setThread({ threadId: credentialRecord.threadId }) + message.setThread({ threadId: credentialRecord.threadId, parentThreadId: credentialRecord.parentThreadId }) await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { agentMessage: message, @@ -399,7 +399,7 @@ export class CredentialFormatCoordinator requestAttachments: requestAttachments, }) - message.setThread({ threadId: credentialRecord.threadId }) + message.setThread({ threadId: credentialRecord.threadId, parentThreadId: credentialRecord.parentThreadId }) await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { agentMessage: message, @@ -498,7 +498,7 @@ export class CredentialFormatCoordinator comment, }) - message.setThread({ threadId: credentialRecord.threadId }) + message.setThread({ threadId: credentialRecord.threadId, parentThreadId: credentialRecord.parentThreadId }) message.setPleaseAck() await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { diff --git a/packages/core/src/modules/credentials/protocol/v2/V2CredentialProtocol.ts b/packages/core/src/modules/credentials/protocol/v2/V2CredentialProtocol.ts index 2ab8eddc4a..5d764fb5aa 100644 --- a/packages/core/src/modules/credentials/protocol/v2/V2CredentialProtocol.ts +++ b/packages/core/src/modules/credentials/protocol/v2/V2CredentialProtocol.ts @@ -211,6 +211,7 @@ export class V2CredentialProtocol { // then expect(credentialRepository.findSingleByQuery).toHaveBeenNthCalledWith(1, agentContext, { threadId: 'somethreadid', - connectionId: connection.id, }) expect(credentialRepository.update).toHaveBeenCalledTimes(1) expect(returnedCredentialRecord.state).toEqual(CredentialState.RequestReceived) @@ -368,15 +367,9 @@ describe('credentialProtocol', () => { const returnedCredentialRecord = await credentialProtocol.processRequest(messageContext) // then - expect(credentialRepository.findSingleByQuery).toHaveBeenNthCalledWith( - 1, - agentContext, - - { - threadId: 'somethreadid', - connectionId: connection.id, - } - ) + expect(credentialRepository.findSingleByQuery).toHaveBeenNthCalledWith(1, agentContext, { + threadId: 'somethreadid', + }) expect(eventListenerMock).toHaveBeenCalled() expect(returnedCredentialRecord.state).toEqual(CredentialState.RequestReceived) }) @@ -617,7 +610,7 @@ describe('credentialProtocol', () => { expect(credentialRepository.getSingleByQuery).toHaveBeenNthCalledWith(1, agentContext, { threadId: 'somethreadid', - connectionId: connection.id, + connectionId: '123', }) expect(returnedCredentialRecord.state).toBe(CredentialState.Done) @@ -685,7 +678,7 @@ describe('credentialProtocol', () => { expect(credentialRepository.getSingleByQuery).toHaveBeenNthCalledWith(1, agentContext, { threadId: 'somethreadid', - connectionId: connection.id, + connectionId: '123', }) expect(credentialRepository.update).toHaveBeenCalled() expect(returnedCredentialRecord.errorMessage).toBe('issuance-abandoned: Indy error') diff --git a/packages/core/src/modules/credentials/repository/CredentialExchangeRecord.ts b/packages/core/src/modules/credentials/repository/CredentialExchangeRecord.ts index c9af8da909..bb1a41c079 100644 --- a/packages/core/src/modules/credentials/repository/CredentialExchangeRecord.ts +++ b/packages/core/src/modules/credentials/repository/CredentialExchangeRecord.ts @@ -17,6 +17,7 @@ export interface CredentialExchangeRecordProps { state: CredentialState connectionId?: string threadId: string + parentThreadId?: string protocolVersion: string tags?: CustomCredentialTags @@ -31,6 +32,7 @@ export interface CredentialExchangeRecordProps { export type CustomCredentialTags = TagsBase export type DefaultCredentialTags = { threadId: string + parentThreadId?: string connectionId?: string state: CredentialState credentialIds: string[] @@ -44,6 +46,7 @@ export interface CredentialRecordBinding { export class CredentialExchangeRecord extends BaseRecord { public connectionId?: string public threadId!: string + public parentThreadId?: string public state!: CredentialState public autoAcceptCredential?: AutoAcceptCredential public revocationNotification?: RevocationNotification @@ -69,6 +72,7 @@ export class CredentialExchangeRecord extends BaseRecord this.emitWithConnection(connectionRecord, messages)) + .then((connectionRecord) => this.emitWithConnection(outOfBandRecord, connectionRecord, messages)) .catch((error) => { if (error instanceof EmptyError) { this.logger.warn( @@ -599,9 +618,9 @@ export class OutOfBandApi { this.logger.debug('Out of band message contains only request messages.') if (existingConnection) { this.logger.debug('Connection already exists.', { connectionId: existingConnection.id }) - await this.emitWithConnection(existingConnection, messages) + await this.emitWithConnection(outOfBandRecord, existingConnection, messages) } else { - await this.emitWithServices(services, messages) + await this.emitWithServices(outOfBandRecord, services, messages) } } return { outOfBandRecord } @@ -744,7 +763,11 @@ export class OutOfBandApi { } } - private async emitWithConnection(connectionRecord: ConnectionRecord, messages: PlaintextMessage[]) { + private async emitWithConnection( + outOfBandRecord: OutOfBandRecord, + connectionRecord: ConnectionRecord, + messages: PlaintextMessage[] + ) { const supportedMessageTypes = this.messageHandlerRegistry.supportedMessageTypes const plaintextMessage = messages.find((message) => { const parsedMessageType = parseMessageType(message['@type']) @@ -755,6 +778,9 @@ export class OutOfBandApi { throw new AriesFrameworkError('There is no message in requests~attach supported by agent.') } + // Make sure message has correct parent thread id + this.ensureParentThreadId(outOfBandRecord, plaintextMessage) + this.logger.debug(`Message with type ${plaintextMessage['@type']} can be processed.`) this.eventEmitter.emit(this.agentContext, { @@ -767,7 +793,11 @@ export class OutOfBandApi { }) } - private async emitWithServices(services: Array, messages: PlaintextMessage[]) { + private async emitWithServices( + outOfBandRecord: OutOfBandRecord, + services: Array, + messages: PlaintextMessage[] + ) { if (!services || services.length === 0) { throw new AriesFrameworkError(`There are no services. We can not emit messages`) } @@ -782,6 +812,9 @@ export class OutOfBandApi { throw new AriesFrameworkError('There is no message in requests~attach supported by agent.') } + // Make sure message has correct parent thread id + this.ensureParentThreadId(outOfBandRecord, plaintextMessage) + this.logger.debug(`Message with type ${plaintextMessage['@type']} can be processed.`) this.eventEmitter.emit(this.agentContext, { @@ -793,6 +826,35 @@ export class OutOfBandApi { }) } + private ensureParentThreadId(outOfBandRecord: OutOfBandRecord, plaintextMessage: PlaintextMessage) { + const legacyInvitationMetadata = outOfBandRecord.metadata.get(OutOfBandRecordMetadataKeys.LegacyInvitation) + + // We need to set the parent thread id to the invitation id, according to RFC 0434. + // So if it already has a pthid and it is not the same as the invitation id, we throw an error + if ( + plaintextMessage['~thread']?.pthid && + plaintextMessage['~thread'].pthid !== outOfBandRecord.outOfBandInvitation.id + ) { + throw new AriesFrameworkError( + `Out of band invitation requests~attach message contains parent thread id ${plaintextMessage['~thread'].pthid} that does not match the invitation id ${outOfBandRecord.outOfBandInvitation.id}` + ) + } + + // If the invitation is created from a legacy connectionless invitation, we don't need to set the pthid + // as that's not expected, and it's generated on our side only + if (legacyInvitationMetadata?.legacyInvitationType === 'connectionless') { + return + } + + if (!plaintextMessage['~thread']) { + plaintextMessage['~thread'] = {} + } + + // The response to an out-of-band message MUST set its ~thread.pthid equal to the @id property of the out-of-band message. + // By adding the pthid to the message, we ensure that the response will take over this pthid + plaintextMessage['~thread'].pthid = outOfBandRecord.outOfBandInvitation.id + } + private async handleHandshakeReuse(outOfBandRecord: OutOfBandRecord, connectionRecord: ConnectionRecord) { const reuseMessage = await this.outOfBandService.createHandShakeReuse( this.agentContext, diff --git a/packages/core/src/modules/oob/helpers.ts b/packages/core/src/modules/oob/helpers.ts index be2fdc1b6e..ccd35e7a31 100644 --- a/packages/core/src/modules/oob/helpers.ts +++ b/packages/core/src/modules/oob/helpers.ts @@ -4,7 +4,7 @@ import { ConnectionInvitationMessage, HandshakeProtocol } from '../connections' import { didKeyToVerkey, verkeyToDidKey } from '../dids/helpers' import { OutOfBandDidCommService } from './domain/OutOfBandDidCommService' -import { OutOfBandInvitation } from './messages' +import { InvitationType, OutOfBandInvitation } from './messages' export function convertToNewInvitation(oldInvitation: ConnectionInvitationMessage) { let service @@ -32,7 +32,9 @@ export function convertToNewInvitation(oldInvitation: ConnectionInvitationMessag handshakeProtocols: [HandshakeProtocol.Connections], } - return new OutOfBandInvitation(options) + const outOfBandInvitation = new OutOfBandInvitation(options) + outOfBandInvitation.invitationType = InvitationType.Connection + return outOfBandInvitation } export function convertToOldInvitation(newInvitation: OutOfBandInvitation) { diff --git a/packages/core/src/modules/oob/messages/OutOfBandInvitation.ts b/packages/core/src/modules/oob/messages/OutOfBandInvitation.ts index 39aec65941..ba310cd1d4 100644 --- a/packages/core/src/modules/oob/messages/OutOfBandInvitation.ts +++ b/packages/core/src/modules/oob/messages/OutOfBandInvitation.ts @@ -1,7 +1,7 @@ import type { PlaintextMessage } from '../../../types' import type { HandshakeProtocol } from '../../connections' -import { Expose, Transform, TransformationType, Type } from 'class-transformer' +import { Exclude, Expose, Transform, TransformationType, Type } from 'class-transformer' import { ArrayNotEmpty, IsArray, IsInstance, IsOptional, IsUrl, ValidateNested } from 'class-validator' import { parseUrl } from 'query-string' @@ -44,6 +44,13 @@ export class OutOfBandInvitation extends AgentMessage { } } + /** + * The original type of the invitation. This is not part of the RFC, but allows to identify + * from what the oob invitation was originally created (e.g. legacy connectionless invitation). + */ + @Exclude() + public invitationType?: InvitationType + public addRequest(message: AgentMessage) { if (!this.requests) this.requests = [] const requestAttachment = new Attachment({ @@ -179,3 +186,12 @@ function OutOfBandServiceTransformer() { return value }) } + +/** + * The original invitation an out of band invitation was derived from. + */ +export enum InvitationType { + OutOfBand = 'out-of-band/1.x', + Connection = 'connections/1.x', + Connectionless = 'connectionless', +} diff --git a/packages/core/src/modules/oob/repository/outOfBandRecordMetadataTypes.ts b/packages/core/src/modules/oob/repository/outOfBandRecordMetadataTypes.ts index f092807324..079339a9bf 100644 --- a/packages/core/src/modules/oob/repository/outOfBandRecordMetadataTypes.ts +++ b/packages/core/src/modules/oob/repository/outOfBandRecordMetadataTypes.ts @@ -1,5 +1,8 @@ +import type { InvitationType } from '../messages' + export enum OutOfBandRecordMetadataKeys { RecipientRouting = '_internal/recipientRouting', + LegacyInvitation = '_internal/legacyInvitation', } export type OutOfBandRecordMetadata = { @@ -9,4 +12,10 @@ export type OutOfBandRecordMetadata = { endpoints: string[] mediatorId?: string } + [OutOfBandRecordMetadataKeys.LegacyInvitation]: { + /** + * Indicates the type of the legacy invitation that was used for this out of band exchange. + */ + legacyInvitationType?: Exclude + } } diff --git a/packages/core/src/modules/proofs/protocol/v2/ProofFormatCoordinator.ts b/packages/core/src/modules/proofs/protocol/v2/ProofFormatCoordinator.ts index 54615d7034..29186ec16f 100644 --- a/packages/core/src/modules/proofs/protocol/v2/ProofFormatCoordinator.ts +++ b/packages/core/src/modules/proofs/protocol/v2/ProofFormatCoordinator.ts @@ -328,7 +328,7 @@ export class ProofFormatCoordinator { goalCode, }) - message.setThread({ threadId: proofRecord.threadId }) + message.setThread({ threadId: proofRecord.threadId, parentThreadId: proofRecord.parentThreadId }) message.setPleaseAck() await didCommMessageRepository.saveOrUpdateAgentMessage(agentContext, { diff --git a/packages/core/src/modules/proofs/protocol/v2/V2ProofProtocol.ts b/packages/core/src/modules/proofs/protocol/v2/V2ProofProtocol.ts index 733fbc9f5c..7a9292e0e8 100644 --- a/packages/core/src/modules/proofs/protocol/v2/V2ProofProtocol.ts +++ b/packages/core/src/modules/proofs/protocol/v2/V2ProofProtocol.ts @@ -642,11 +642,7 @@ export class V2ProofProtocol { }) }) + describe('messages and connection exchange', () => { + test('oob exchange with handshake where response is received to invitation', async () => { + const { message } = await faberAgent.credentials.createOffer(credentialTemplate) + const outOfBandRecord = await faberAgent.oob.createInvitation({ + handshake: true, + messages: [message], + }) + const { outOfBandInvitation } = outOfBandRecord + + await aliceAgent.oob.receiveInvitation(outOfBandInvitation) + + const aliceCredentialRecordPromise = waitForCredentialRecord(aliceAgent, { + state: CredentialState.OfferReceived, + threadId: message.threadId, + timeoutMs: 10000, + }) + + const aliceCredentialRecord = await aliceCredentialRecordPromise + expect(aliceCredentialRecord.state).toBe(CredentialState.OfferReceived) + + // If we receive the event, we know the processing went well + const faberCredentialRecordPromise = waitForCredentialRecord(faberAgent, { + state: CredentialState.RequestReceived, + threadId: message.threadId, + timeoutMs: 10000, + }) + + await aliceAgent.credentials.acceptOffer({ + credentialRecordId: aliceCredentialRecord.id, + }) + + await faberCredentialRecordPromise + }) + + test('oob exchange with reuse where response is received to invitation', async () => { + const { message } = await faberAgent.credentials.createOffer(credentialTemplate) + + const routing = await faberAgent.mediationRecipient.getRouting({}) + const connectionOutOfBandRecord = await faberAgent.oob.createInvitation({ + routing, + }) + + // Create connection + const { connectionRecord } = await aliceAgent.oob.receiveInvitation(connectionOutOfBandRecord.outOfBandInvitation) + if (!connectionRecord) throw new Error('Connection record is undefined') + await aliceAgent.connections.returnWhenIsConnected(connectionRecord.id) + + // Create offer and reuse + const outOfBandRecord = await faberAgent.oob.createInvitation({ + routing, + messages: [message], + }) + // Create connection + const { connectionRecord: offerConnectionRecord } = await aliceAgent.oob.receiveInvitation( + outOfBandRecord.outOfBandInvitation, + { + reuseConnection: true, + } + ) + if (!offerConnectionRecord) throw new Error('Connection record is undefined') + + // Should be the same, as connection is reused. + expect(offerConnectionRecord.id).toEqual(connectionRecord.id) + + const aliceCredentialRecordPromise = waitForCredentialRecord(aliceAgent, { + state: CredentialState.OfferReceived, + threadId: message.threadId, + timeoutMs: 10000, + }) + + const aliceCredentialRecord = await aliceCredentialRecordPromise + expect(aliceCredentialRecord.state).toBe(CredentialState.OfferReceived) + + // If we receive the event, we know the processing went well + const faberCredentialRecordPromise = waitForCredentialRecord(faberAgent, { + state: CredentialState.RequestReceived, + threadId: message.threadId, + timeoutMs: 10000, + }) + + await aliceAgent.credentials.acceptOffer({ + credentialRecordId: aliceCredentialRecord.id, + }) + + await faberCredentialRecordPromise + }) + }) + describe('connection-less exchange', () => { test('oob exchange without handshake where response is received to invitation', async () => { const { message } = await faberAgent.credentials.createOffer(credentialTemplate)