From adc7d4ecfea9be5f707ab7b50d19dbe7690c6d25 Mon Sep 17 00:00:00 2001 From: Ariel Gentile Date: Mon, 29 Jan 2024 16:05:40 -0300 Subject: [PATCH] feat: did rotate (#1699) Signed-off-by: Ariel Gentile --- .../core/src/agent/__tests__/Agent.test.ts | 6 +- .../src/modules/connections/ConnectionsApi.ts | 122 +++++- .../modules/connections/ConnectionsModule.ts | 9 +- .../connections/ConnectionsModuleConfig.ts | 21 + .../connections/DidExchangeProtocol.ts | 122 ++---- .../__tests__/ConnectionsModule.test.ts | 4 +- .../__tests__/did-rotate.e2e.test.ts | 385 ++++++++++++++++++ .../__tests__/didexchange-numalgo.e2e.test.ts | 13 +- .../handlers/DidRotateAckHandler.ts | 17 + .../connections/handlers/DidRotateHandler.ts | 26 ++ .../handlers/DidRotateProblemReportHandler.ts | 17 + .../connections/handlers/HangupHandler.ts | 17 + .../src/modules/connections/handlers/index.ts | 4 + .../messages/DidRotateAckMessage.ts | 20 + .../connections/messages/DidRotateMessage.ts | 38 ++ .../messages/DidRotateProblemReportMessage.ts | 19 + .../connections/messages/HangupMessage.ts | 30 ++ .../src/modules/connections/messages/index.ts | 4 + .../connections/models/DidRotateRole.ts | 4 + .../src/modules/connections/models/index.ts | 1 + .../repository/ConnectionMetadataTypes.ts | 6 + .../repository/ConnectionRecord.ts | 10 + .../repository/ConnectionRepository.ts | 10 +- .../__tests__/ConnectionRecord.test.ts | 2 + .../connections/services/DidRotateService.ts | 276 +++++++++++++ .../modules/connections/services/helpers.ts | 59 ++- .../src/modules/connections/services/index.ts | 1 + .../src/modules/routing/services/helpers.ts | 13 + .../__tests__/__snapshots__/0.1.test.ts.snap | 28 ++ .../0.1-0.2/__tests__/connection.test.ts | 14 + .../0.2-0.3/__tests__/connection.test.ts | 8 + packages/core/tests/helpers.ts | 58 ++- tests/transport/SubjectInboundTransport.ts | 11 +- 33 files changed, 1267 insertions(+), 108 deletions(-) create mode 100644 packages/core/src/modules/connections/__tests__/did-rotate.e2e.test.ts create mode 100644 packages/core/src/modules/connections/handlers/DidRotateAckHandler.ts create mode 100644 packages/core/src/modules/connections/handlers/DidRotateHandler.ts create mode 100644 packages/core/src/modules/connections/handlers/DidRotateProblemReportHandler.ts create mode 100644 packages/core/src/modules/connections/handlers/HangupHandler.ts create mode 100644 packages/core/src/modules/connections/messages/DidRotateAckMessage.ts create mode 100644 packages/core/src/modules/connections/messages/DidRotateMessage.ts create mode 100644 packages/core/src/modules/connections/messages/DidRotateProblemReportMessage.ts create mode 100644 packages/core/src/modules/connections/messages/HangupMessage.ts create mode 100644 packages/core/src/modules/connections/models/DidRotateRole.ts create mode 100644 packages/core/src/modules/connections/services/DidRotateService.ts create mode 100644 packages/core/src/modules/routing/services/helpers.ts diff --git a/packages/core/src/agent/__tests__/Agent.test.ts b/packages/core/src/agent/__tests__/Agent.test.ts index c75d820c0f..cddd819d76 100644 --- a/packages/core/src/agent/__tests__/Agent.test.ts +++ b/packages/core/src/agent/__tests__/Agent.test.ts @@ -7,6 +7,7 @@ import { getAgentOptions } from '../../../tests/helpers' import { InjectionSymbols } from '../../constants' import { BasicMessageRepository, BasicMessageService } from '../../modules/basic-messages' import { BasicMessagesApi } from '../../modules/basic-messages/BasicMessagesApi' +import { DidRotateService } from '../../modules/connections' import { ConnectionsApi } from '../../modules/connections/ConnectionsApi' import { ConnectionRepository } from '../../modules/connections/repository/ConnectionRepository' import { ConnectionService } from '../../modules/connections/services/ConnectionService' @@ -158,6 +159,7 @@ describe('Agent', () => { expect(container.resolve(ConnectionsApi)).toBeInstanceOf(ConnectionsApi) expect(container.resolve(ConnectionService)).toBeInstanceOf(ConnectionService) expect(container.resolve(ConnectionRepository)).toBeInstanceOf(ConnectionRepository) + expect(container.resolve(DidRotateService)).toBeInstanceOf(DidRotateService) expect(container.resolve(TrustPingService)).toBeInstanceOf(TrustPingService) expect(container.resolve(ProofsApi)).toBeInstanceOf(ProofsApi) @@ -197,6 +199,7 @@ describe('Agent', () => { expect(container.resolve(ConnectionService)).toBe(container.resolve(ConnectionService)) expect(container.resolve(ConnectionRepository)).toBe(container.resolve(ConnectionRepository)) expect(container.resolve(TrustPingService)).toBe(container.resolve(TrustPingService)) + expect(container.resolve(DidRotateService)).toBe(container.resolve(DidRotateService)) expect(container.resolve(ProofsApi)).toBe(container.resolve(ProofsApi)) expect(container.resolve(ProofRepository)).toBe(container.resolve(ProofRepository)) @@ -247,6 +250,7 @@ describe('Agent', () => { 'https://didcomm.org/issue-credential/2.0', 'https://didcomm.org/present-proof/2.0', 'https://didcomm.org/didexchange/1.1', + 'https://didcomm.org/did-rotate/1.0', 'https://didcomm.org/discover-features/1.0', 'https://didcomm.org/discover-features/2.0', 'https://didcomm.org/messagepickup/1.0', @@ -256,6 +260,6 @@ describe('Agent', () => { 'https://didcomm.org/revocation_notification/2.0', ]) ) - expect(protocols.length).toEqual(13) + expect(protocols.length).toEqual(14) }) }) diff --git a/packages/core/src/modules/connections/ConnectionsApi.ts b/packages/core/src/modules/connections/ConnectionsApi.ts index 58ffbcf8c8..fd1051bc06 100644 --- a/packages/core/src/modules/connections/ConnectionsApi.ts +++ b/packages/core/src/modules/connections/ConnectionsApi.ts @@ -15,6 +15,7 @@ import { DidResolverService } from '../dids' import { DidRepository } from '../dids/repository' import { OutOfBandService } from '../oob/OutOfBandService' import { RoutingService } from '../routing/services/RoutingService' +import { getMediationRecordForDidDocument } from '../routing/services/helpers' import { ConnectionsModuleConfig } from './ConnectionsModuleConfig' import { DidExchangeProtocol } from './DidExchangeProtocol' @@ -28,8 +29,13 @@ import { TrustPingMessageHandler, TrustPingResponseMessageHandler, ConnectionProblemReportHandler, + DidRotateHandler, + DidRotateAckHandler, + DidRotateProblemReportHandler, + HangupHandler, } from './handlers' import { HandshakeProtocol } from './models' +import { DidRotateService } from './services' import { ConnectionService } from './services/ConnectionService' import { TrustPingService } from './services/TrustPingService' @@ -47,6 +53,7 @@ export class ConnectionsApi { private didExchangeProtocol: DidExchangeProtocol private connectionService: ConnectionService + private didRotateService: DidRotateService private outOfBandService: OutOfBandService private messageSender: MessageSender private trustPingService: TrustPingService @@ -59,6 +66,7 @@ export class ConnectionsApi { messageHandlerRegistry: MessageHandlerRegistry, didExchangeProtocol: DidExchangeProtocol, connectionService: ConnectionService, + didRotateService: DidRotateService, outOfBandService: OutOfBandService, trustPingService: TrustPingService, routingService: RoutingService, @@ -70,6 +78,7 @@ export class ConnectionsApi { ) { this.didExchangeProtocol = didExchangeProtocol this.connectionService = connectionService + this.didRotateService = didRotateService this.outOfBandService = outOfBandService this.trustPingService = trustPingService this.routingService = routingService @@ -96,8 +105,8 @@ export class ConnectionsApi { ) { const { protocol, label, alias, imageUrl, autoAcceptConnection, ourDid } = config - if (ourDid && !config.routing) { - throw new AriesFrameworkError('If an external did is specified, routing configuration must be defined as well') + if (ourDid && config.routing) { + throw new AriesFrameworkError(`'routing' is disallowed when defining 'ourDid'`) } const routing = @@ -278,6 +287,74 @@ export class ConnectionsApi { return message } + /** + * Rotate the DID used for a given connection, notifying the other party immediately. + * + * If `toDid` is not specified, a new peer did will be created. Optionally, routing + * configuration can be set. + * + * Note: any did created or imported in agent wallet can be used as `toDid`, as long as + * there are valid DIDComm services in its DID Document. + * + * @param options connectionId and optional target did and routing configuration + * @returns object containing the new did + */ + public async rotate(options: { connectionId: string; toDid?: string; routing?: Routing }) { + const { connectionId, toDid } = options + const connection = await this.connectionService.getById(this.agentContext, connectionId) + + if (toDid && options.routing) { + throw new AriesFrameworkError(`'routing' is disallowed when defining 'toDid'`) + } + + let routing = options.routing + if (!toDid && !routing) { + routing = await this.routingService.getRouting(this.agentContext, {}) + } + + const message = await this.didRotateService.createRotate(this.agentContext, { + connection, + toDid, + routing, + }) + + const outboundMessageContext = new OutboundMessageContext(message, { + agentContext: this.agentContext, + connection, + }) + + await this.messageSender.sendMessage(outboundMessageContext) + + return { newDid: message.toDid } + } + + /** + * Terminate a connection by sending a hang-up message to the other party. The connection record itself and any + * keys used for mediation will only be deleted if `deleteAfterHangup` flag is set. + * + * @param options connectionId + */ + public async hangup(options: { connectionId: string; deleteAfterHangup?: boolean }) { + const connection = await this.connectionService.getById(this.agentContext, options.connectionId) + + const connectionBeforeHangup = connection.clone() + + // Create Hangup message and update did in connection record + const message = await this.didRotateService.createHangup(this.agentContext, { connection }) + + const outboundMessageContext = new OutboundMessageContext(message, { + agentContext: this.agentContext, + connection: connectionBeforeHangup, + }) + + await this.messageSender.sendMessage(outboundMessageContext) + + // After hang-up message submission, delete connection if required + if (options.deleteAfterHangup) { + await this.deleteById(connection.id) + } + } + public async returnWhenIsConnected(connectionId: string, options?: { timeoutMs: number }): Promise { return this.connectionService.returnWhenIsConnected(this.agentContext, connectionId, options?.timeoutMs) } @@ -394,6 +471,39 @@ export class ConnectionsApi { return this.connectionService.deleteById(this.agentContext, connectionId) } + /** + * Remove relationship of a connection with any previous did (either ours or theirs), preventing it from accepting + * messages from them. This is usually called when a DID Rotation flow has been succesful and we are sure that no + * more messages with older keys will arrive. + * + * It will remove routing keys from mediator if applicable. + * + * Note: this will not actually delete any DID from the wallet. + * + * @param connectionId + */ + public async removePreviousDids(options: { connectionId: string }) { + const connection = await this.connectionService.getById(this.agentContext, options.connectionId) + + for (const previousDid of connection.previousDids) { + const did = await this.didResolverService.resolve(this.agentContext, previousDid) + if (!did.didDocument) continue + const mediatorRecord = await getMediationRecordForDidDocument(this.agentContext, did.didDocument) + + if (mediatorRecord) { + await this.routingService.removeRouting(this.agentContext, { + recipientKeys: did.didDocument.recipientKeys, + mediatorId: mediatorRecord.id, + }) + } + } + + connection.previousDids = [] + connection.previousTheirDids = [] + + await this.connectionService.update(this.agentContext, connection) + } + public async findAllByOutOfBandId(outOfBandId: string) { return this.connectionService.findAllByOutOfBandId(this.agentContext, outOfBandId) } @@ -460,5 +570,13 @@ export class ConnectionsApi { messageHandlerRegistry.registerMessageHandler( new DidExchangeCompleteHandler(this.didExchangeProtocol, this.outOfBandService) ) + + messageHandlerRegistry.registerMessageHandler(new DidRotateHandler(this.didRotateService, this.connectionService)) + + messageHandlerRegistry.registerMessageHandler(new DidRotateAckHandler(this.didRotateService)) + + messageHandlerRegistry.registerMessageHandler(new HangupHandler(this.didRotateService)) + + messageHandlerRegistry.registerMessageHandler(new DidRotateProblemReportHandler(this.didRotateService)) } } diff --git a/packages/core/src/modules/connections/ConnectionsModule.ts b/packages/core/src/modules/connections/ConnectionsModule.ts index 25df6cb044..dcddf81da3 100644 --- a/packages/core/src/modules/connections/ConnectionsModule.ts +++ b/packages/core/src/modules/connections/ConnectionsModule.ts @@ -7,9 +7,9 @@ import { Protocol } from '../../agent/models' import { ConnectionsApi } from './ConnectionsApi' import { ConnectionsModuleConfig } from './ConnectionsModuleConfig' import { DidExchangeProtocol } from './DidExchangeProtocol' -import { ConnectionRole, DidExchangeRole } from './models' +import { ConnectionRole, DidExchangeRole, DidRotateRole } from './models' import { ConnectionRepository } from './repository' -import { ConnectionService, TrustPingService } from './services' +import { ConnectionService, DidRotateService, TrustPingService } from './services' export class ConnectionsModule implements Module { public readonly config: ConnectionsModuleConfig @@ -32,6 +32,7 @@ export class ConnectionsModule implements Module { // Services dependencyManager.registerSingleton(ConnectionService) dependencyManager.registerSingleton(DidExchangeProtocol) + dependencyManager.registerSingleton(DidRotateService) dependencyManager.registerSingleton(TrustPingService) // Repositories @@ -46,6 +47,10 @@ export class ConnectionsModule implements Module { new Protocol({ id: 'https://didcomm.org/didexchange/1.1', roles: [DidExchangeRole.Requester, DidExchangeRole.Responder], + }), + new Protocol({ + id: 'https://didcomm.org/did-rotate/1.0', + roles: [DidRotateRole.RotatingParty, DidRotateRole.ObservingParty], }) ) } diff --git a/packages/core/src/modules/connections/ConnectionsModuleConfig.ts b/packages/core/src/modules/connections/ConnectionsModuleConfig.ts index 86465b293b..a978241c70 100644 --- a/packages/core/src/modules/connections/ConnectionsModuleConfig.ts +++ b/packages/core/src/modules/connections/ConnectionsModuleConfig.ts @@ -23,11 +23,19 @@ export interface ConnectionsModuleConfigOptions { * @default PeerDidNumAlgo.GenesisDoc */ peerNumAlgoForDidExchangeRequests?: PeerDidNumAlgo + + /** + * Peer did num algo to use for DID rotation (RFC 0794). + * + * @default PeerDidNumAlgo.ShortFormAndLongForm + */ + peerNumAlgoForDidRotation?: PeerDidNumAlgo.MultipleInceptionKeyWithoutDoc | PeerDidNumAlgo.ShortFormAndLongForm } export class ConnectionsModuleConfig { #autoAcceptConnections?: boolean #peerNumAlgoForDidExchangeRequests?: PeerDidNumAlgo + #peerNumAlgoForDidRotation?: PeerDidNumAlgo.MultipleInceptionKeyWithoutDoc | PeerDidNumAlgo.ShortFormAndLongForm private options: ConnectionsModuleConfigOptions @@ -35,6 +43,7 @@ export class ConnectionsModuleConfig { this.options = options ?? {} this.#autoAcceptConnections = this.options.autoAcceptConnections this.#peerNumAlgoForDidExchangeRequests = this.options.peerNumAlgoForDidExchangeRequests + this.#peerNumAlgoForDidRotation = this.options.peerNumAlgoForDidRotation } /** See {@link ConnectionsModuleConfigOptions.autoAcceptConnections} */ @@ -56,4 +65,16 @@ export class ConnectionsModuleConfig { public set peerNumAlgoForDidExchangeRequests(peerNumAlgoForDidExchangeRequests: PeerDidNumAlgo) { this.#peerNumAlgoForDidExchangeRequests = peerNumAlgoForDidExchangeRequests } + + /** See {@link ConnectionsModuleConfigOptions.peerNumAlgoForDidRotation} */ + public get peerNumAlgoForDidRotation() { + return this.#peerNumAlgoForDidRotation ?? PeerDidNumAlgo.ShortFormAndLongForm + } + + /** See {@link ConnectionsModuleConfigOptions.peerNumAlgoForDidRotation} */ + public set peerNumAlgoForDidRotation( + peerNumAlgoForDidRotation: PeerDidNumAlgo.MultipleInceptionKeyWithoutDoc | PeerDidNumAlgo.ShortFormAndLongForm + ) { + this.#peerNumAlgoForDidRotation = peerNumAlgoForDidRotation + } } diff --git a/packages/core/src/modules/connections/DidExchangeProtocol.ts b/packages/core/src/modules/connections/DidExchangeProtocol.ts index 1a7a1a5211..92eb8111d7 100644 --- a/packages/core/src/modules/connections/DidExchangeProtocol.ts +++ b/packages/core/src/modules/connections/DidExchangeProtocol.ts @@ -21,7 +21,6 @@ import { JsonTransformer } from '../../utils/JsonTransformer' import { base64ToBase64URL } from '../../utils/base64' import { DidDocument, - createPeerDidDocumentFromServices, DidKey, getNumAlgoFromPeerDid, PeerDidNumAlgo, @@ -35,7 +34,7 @@ import { didKeyToInstanceOfKey } from '../dids/helpers' import { DidRepository } from '../dids/repository' import { OutOfBandRole } from '../oob/domain/OutOfBandRole' import { OutOfBandState } from '../oob/domain/OutOfBandState' -import { MediationRecipientService } from '../routing/services/MediationRecipientService' +import { getMediationRecordForDidDocument } from '../routing/services/helpers' import { ConnectionsModuleConfig } from './ConnectionsModuleConfig' import { DidExchangeStateMachine } from './DidExchangeStateMachine' @@ -43,6 +42,7 @@ import { DidExchangeProblemReportError, DidExchangeProblemReportReason } from '. import { DidExchangeRequestMessage, DidExchangeResponseMessage, DidExchangeCompleteMessage } from './messages' import { DidExchangeRole, DidExchangeState, HandshakeProtocol } from './models' import { ConnectionService } from './services' +import { createPeerDidFromServices, getDidDocumentForCreatedDid, routingToServices } from './services/helpers' interface DidExchangeRequestParams { label?: string @@ -97,22 +97,15 @@ export class DidExchangeProtocol { // If our did is specified, make sure we have all key material for it if (did) { - if (routing) throw new AriesFrameworkError(`'routing' is disallowed when defining 'ourDid'`) - - didDocument = await this.getDidDocumentForCreatedDid(agentContext, did) - const [mediatorRecord] = await agentContext.dependencyManager - .resolve(MediationRecipientService) - .findAllMediatorsByQuery(agentContext, { - recipientKeys: didDocument.recipientKeys.map((key) => key.publicKeyBase58), - }) - mediatorId = mediatorRecord?.id + didDocument = await getDidDocumentForCreatedDid(agentContext, did) + mediatorId = (await getMediationRecordForDidDocument(agentContext, didDocument))?.id // Otherwise, create a did:peer based on the provided routing } else { if (!routing) throw new AriesFrameworkError(`'routing' must be defined if 'ourDid' is not specified`) - didDocument = await this.createPeerDidDoc( + didDocument = await createPeerDidFromServices( agentContext, - this.routingToServices(routing), + routingToServices(routing), config.peerNumAlgoForDidExchangeRequests ) mediatorId = routing.mediatorId @@ -194,31 +187,33 @@ export class DidExchangeProtocol { // Get DID Document either from message (if it is a supported did:peer) or resolve it externally const didDocument = await this.resolveDidDocument(agentContext, message) - if (isValidPeerDid(didDocument.id)) { - const didRecord = await this.didRepository.storeReceivedDid(messageContext.agentContext, { - did: didDocument.id, - // It is important to take the did document from the PeerDid class - // as it will have the id property - didDocument: getNumAlgoFromPeerDid(message.did) === PeerDidNumAlgo.GenesisDoc ? didDocument : undefined, - tags: { - // We need to save the recipientKeys, so we can find the associated did - // of a key when we receive a message from another connection. - recipientKeyFingerprints: didDocument.recipientKeys.map((key) => key.fingerprint), - - // For did:peer, store any alternative dids (like short form did:peer:4), - // it may have in order to relate any message referencing it - alternativeDids: getAlternativeDidsForPeerDid(didDocument.id), - }, - }) + // A DID Record must be stored in order to allow for searching for its recipient keys when receiving a message + const didRecord = await this.didRepository.storeReceivedDid(messageContext.agentContext, { + did: didDocument.id, + // It is important to take the did document from the PeerDid class + // as it will have the id property + didDocument: + !isValidPeerDid(didDocument.id) || getNumAlgoFromPeerDid(message.did) === PeerDidNumAlgo.GenesisDoc + ? didDocument + : undefined, + tags: { + // We need to save the recipientKeys, so we can find the associated did + // of a key when we receive a message from another connection. + recipientKeyFingerprints: didDocument.recipientKeys.map((key) => key.fingerprint), + + // For did:peer, store any alternative dids (like short form did:peer:4), + // it may have in order to relate any message referencing it + alternativeDids: isValidPeerDid(didDocument.id) ? getAlternativeDidsForPeerDid(didDocument.id) : undefined, + }, + }) - this.logger.debug('Saved DID record', { - id: didRecord.id, - did: didRecord.did, - role: didRecord.role, - tags: didRecord.getTags(), - didDocument: 'omitted...', - }) - } + this.logger.debug('Saved DID record', { + id: didRecord.id, + did: didRecord.did, + role: didRecord.role, + tags: didRecord.getTags(), + didDocument: 'omitted...', + }) const connectionRecord = await this.connectionService.createConnection(messageContext.agentContext, { protocol: HandshakeProtocol.DidExchange, @@ -261,7 +256,7 @@ export class DidExchangeProtocol { let services: ResolvedDidCommService[] = [] if (routing) { - services = this.routingToServices(routing) + services = routingToServices(routing) } else if (outOfBandRecord) { const inlineServices = outOfBandRecord.outOfBandInvitation.getInlineServices() services = inlineServices.map((service) => ({ @@ -277,7 +272,7 @@ export class DidExchangeProtocol { ? getNumAlgoFromPeerDid(theirDid) : config.peerNumAlgoForDidExchangeRequests - const didDocument = await this.createPeerDidDoc(agentContext, services, numAlgo) + const didDocument = await createPeerDidFromServices(agentContext, services, numAlgo) const message = new DidExchangeResponseMessage({ did: didDocument.id, threadId }) if (numAlgo === PeerDidNumAlgo.GenesisDoc) { @@ -455,46 +450,6 @@ export class DidExchangeProtocol { return this.connectionService.updateState(agentContext, connectionRecord, nextState) } - private async createPeerDidDoc( - agentContext: AgentContext, - services: ResolvedDidCommService[], - numAlgo: PeerDidNumAlgo - ) { - const didsApi = agentContext.dependencyManager.resolve(DidsApi) - - // Create did document without the id property - const didDocument = createPeerDidDocumentFromServices(services) - // Register did:peer document. This will generate the id property and save it to a did record - - const result = await didsApi.create({ - method: 'peer', - didDocument, - options: { - numAlgo, - }, - }) - - if (result.didState?.state !== 'finished') { - throw new AriesFrameworkError(`Did document creation failed: ${JSON.stringify(result.didState)}`) - } - - this.logger.debug(`Did document with did ${result.didState.did} created.`, { - did: result.didState.did, - didDocument: result.didState.didDocument, - }) - - return result.didState.didDocument - } - - private async getDidDocumentForCreatedDid(agentContext: AgentContext, did: string) { - const didRecord = await this.didRepository.findCreatedDid(agentContext, did) - - if (!didRecord?.didDocument) { - throw new AriesFrameworkError(`Could not get DidDocument for created did ${did}`) - } - return didRecord.didDocument - } - private async createSignedAttachment( agentContext: AgentContext, data: string | Record, @@ -712,13 +667,4 @@ export class DidExchangeProtocol { return didDocument } - - private routingToServices(routing: Routing): ResolvedDidCommService[] { - return routing.endpoints.map((endpoint, index) => ({ - id: `#inline-${index}`, - serviceEndpoint: endpoint, - recipientKeys: [routing.recipientKey], - routingKeys: routing.routingKeys, - })) - } } diff --git a/packages/core/src/modules/connections/__tests__/ConnectionsModule.test.ts b/packages/core/src/modules/connections/__tests__/ConnectionsModule.test.ts index 34ebb476f7..8fe0127226 100644 --- a/packages/core/src/modules/connections/__tests__/ConnectionsModule.test.ts +++ b/packages/core/src/modules/connections/__tests__/ConnectionsModule.test.ts @@ -6,6 +6,7 @@ import { ConnectionsModuleConfig } from '../ConnectionsModuleConfig' import { DidExchangeProtocol } from '../DidExchangeProtocol' import { ConnectionRepository } from '../repository' import { ConnectionService, TrustPingService } from '../services' +import { DidRotateService } from '../services/DidRotateService' jest.mock('../../../plugins/DependencyManager') const DependencyManagerMock = DependencyManager as jest.Mock @@ -28,10 +29,11 @@ describe('ConnectionsModule', () => { expect(dependencyManager.registerInstance).toHaveBeenCalledTimes(1) expect(dependencyManager.registerInstance).toHaveBeenCalledWith(ConnectionsModuleConfig, connectionsModule.config) - expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(4) + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(5) expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(ConnectionService) expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(DidExchangeProtocol) expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(TrustPingService) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(DidRotateService) expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(ConnectionRepository) }) }) diff --git a/packages/core/src/modules/connections/__tests__/did-rotate.e2e.test.ts b/packages/core/src/modules/connections/__tests__/did-rotate.e2e.test.ts new file mode 100644 index 0000000000..14c8496094 --- /dev/null +++ b/packages/core/src/modules/connections/__tests__/did-rotate.e2e.test.ts @@ -0,0 +1,385 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ + +import type { ConnectionRecord } from '../repository' + +import { ReplaySubject, first, firstValueFrom, timeout } from 'rxjs' + +import { MessageSender } from '../../..//agent/MessageSender' +import { getIndySdkModules } from '../../../../../indy-sdk/tests/setupIndySdkModule' +import { setupSubjectTransports, testLogger } from '../../../../tests' +import { + getAgentOptions, + makeConnection, + waitForAgentMessageProcessedEvent, + waitForBasicMessage, +} from '../../../../tests/helpers' +import { Agent } from '../../../agent/Agent' +import { getOutboundMessageContext } from '../../../agent/getOutboundMessageContext' +import { RecordNotFoundError } from '../../../error' +import { uuid } from '../../../utils/uuid' +import { BasicMessage } from '../../basic-messages' +import { createPeerDidDocumentFromServices } from '../../dids' +import { ConnectionsModule } from '../ConnectionsModule' +import { DidRotateProblemReportMessage, HangupMessage, DidRotateAckMessage } from '../messages' + +import { InMemoryDidRegistry } from './InMemoryDidRegistry' + +// This is the most common flow +describe('Rotation E2E tests', () => { + let aliceAgent: Agent + let bobAgent: Agent + let aliceBobConnection: ConnectionRecord | undefined + let bobAliceConnection: ConnectionRecord | undefined + + beforeEach(async () => { + const aliceAgentOptions = getAgentOptions( + 'DID Rotate Alice', + { + label: 'alice', + endpoints: ['rxjs:alice'], + logger: testLogger, + }, + { + ...getIndySdkModules(), + connections: new ConnectionsModule({ + autoAcceptConnections: true, + }), + } + ) + const bobAgentOptions = getAgentOptions( + 'DID Rotate Bob', + { + label: 'bob', + endpoints: ['rxjs:bob'], + logger: testLogger, + }, + { + ...getIndySdkModules(), + connections: new ConnectionsModule({ + autoAcceptConnections: true, + }), + } + ) + + aliceAgent = new Agent(aliceAgentOptions) + bobAgent = new Agent(bobAgentOptions) + + setupSubjectTransports([aliceAgent, bobAgent]) + await aliceAgent.initialize() + await bobAgent.initialize() + ;[aliceBobConnection, bobAliceConnection] = await makeConnection(aliceAgent, bobAgent) + }) + + afterEach(async () => { + await aliceAgent.shutdown() + await aliceAgent.wallet.delete() + await bobAgent.shutdown() + await bobAgent.wallet.delete() + }) + + describe('Rotation from did:peer:1 to did:peer:4', () => { + test('Rotate succesfully and send messages to new did afterwards', async () => { + const oldDid = aliceBobConnection!.did + expect(bobAliceConnection!.theirDid).toEqual(oldDid) + + // Send message to initial did + await bobAgent.basicMessages.sendMessage(bobAliceConnection!.id, 'Hello initial did') + + await waitForBasicMessage(aliceAgent, { content: 'Hello initial did' }) + + // Do did rotate + const { newDid } = await aliceAgent.connections.rotate({ connectionId: aliceBobConnection!.id }) + + // Wait for acknowledge + await waitForAgentMessageProcessedEvent(aliceAgent, { messageType: DidRotateAckMessage.type.messageTypeUri }) + + // Check that new did is taken into account by both parties + const newAliceBobConnection = await aliceAgent.connections.getById(aliceBobConnection!.id) + const newBobAliceConnection = await bobAgent.connections.getById(bobAliceConnection!.id) + + expect(newAliceBobConnection.did).toEqual(newDid) + expect(newBobAliceConnection.theirDid).toEqual(newDid) + + // And also they store it into previous dids array + expect(newAliceBobConnection.previousDids).toContain(oldDid) + expect(newBobAliceConnection.previousTheirDids).toContain(oldDid) + + // Send message to new did + await bobAgent.basicMessages.sendMessage(bobAliceConnection!.id, 'Hello new did') + + await waitForBasicMessage(aliceAgent, { content: 'Hello new did', connectionId: aliceBobConnection!.id }) + }) + + test('Rotate succesfully and send messages to previous did afterwards', async () => { + // Send message to initial did + await bobAgent.basicMessages.sendMessage(bobAliceConnection!.id, 'Hello initial did') + + await waitForBasicMessage(aliceAgent, { content: 'Hello initial did' }) + + const messageToPreviousDid = await getOutboundMessageContext(bobAgent.context, { + message: new BasicMessage({ content: 'Message to previous did' }), + connectionRecord: bobAliceConnection, + }) + + // Do did rotate + await aliceAgent.connections.rotate({ connectionId: aliceBobConnection!.id }) + + // Wait for acknowledge + await waitForAgentMessageProcessedEvent(aliceAgent, { messageType: DidRotateAckMessage.type.messageTypeUri }) + + // Send message to previous did + await bobAgent.dependencyManager.resolve(MessageSender).sendMessage(messageToPreviousDid) + + await waitForBasicMessage(aliceAgent, { + content: 'Message to previous did', + connectionId: aliceBobConnection!.id, + }) + }) + }) + + describe('Rotation specifying did and routing externally', () => { + test('Rotate succesfully and send messages to new did afterwards', async () => { + const oldDid = aliceBobConnection!.did + expect(bobAliceConnection!.theirDid).toEqual(oldDid) + + // Send message to initial did + await bobAgent.basicMessages.sendMessage(bobAliceConnection!.id, 'Hello initial did') + + await waitForBasicMessage(aliceAgent, { content: 'Hello initial did' }) + + // Create a new external did + + // Make a common in-memory did registry for both agents + const didRegistry = new InMemoryDidRegistry() + aliceAgent.dids.config.addRegistrar(didRegistry) + aliceAgent.dids.config.addResolver(didRegistry) + bobAgent.dids.config.addRegistrar(didRegistry) + bobAgent.dids.config.addResolver(didRegistry) + + const didRouting = await aliceAgent.mediationRecipient.getRouting({}) + const did = `did:inmemory:${uuid()}` + const didDocument = createPeerDidDocumentFromServices([ + { + id: 'didcomm', + recipientKeys: [didRouting.recipientKey], + routingKeys: didRouting.routingKeys, + serviceEndpoint: didRouting.endpoints[0], + }, + ]) + didDocument.id = did + + await aliceAgent.dids.create({ + did, + didDocument, + }) + + // Do did rotate + const { newDid } = await aliceAgent.connections.rotate({ + connectionId: aliceBobConnection!.id, + toDid: did, + }) + + // Wait for acknowledge + await waitForAgentMessageProcessedEvent(aliceAgent, { messageType: DidRotateAckMessage.type.messageTypeUri }) + + // Check that new did is taken into account by both parties + const newAliceBobConnection = await aliceAgent.connections.getById(aliceBobConnection!.id) + const newBobAliceConnection = await bobAgent.connections.getById(bobAliceConnection!.id) + + expect(newAliceBobConnection.did).toEqual(newDid) + expect(newBobAliceConnection.theirDid).toEqual(newDid) + + // And also they store it into previous dids array + expect(newAliceBobConnection.previousDids).toContain(oldDid) + expect(newBobAliceConnection.previousTheirDids).toContain(oldDid) + + // Send message to new did + await bobAgent.basicMessages.sendMessage(bobAliceConnection!.id, 'Hello new did') + + await waitForBasicMessage(aliceAgent, { content: 'Hello new did', connectionId: aliceBobConnection!.id }) + }) + + test('Rotate succesfully and send messages to previous did afterwards', async () => { + // Send message to initial did + await bobAgent.basicMessages.sendMessage(bobAliceConnection!.id, 'Hello initial did') + + await waitForBasicMessage(aliceAgent, { content: 'Hello initial did' }) + + const messageToPreviousDid = await getOutboundMessageContext(bobAgent.context, { + message: new BasicMessage({ content: 'Message to previous did' }), + connectionRecord: bobAliceConnection, + }) + + // Create a new external did + + // Make a common in-memory did registry for both agents + const didRegistry = new InMemoryDidRegistry() + aliceAgent.dids.config.addRegistrar(didRegistry) + aliceAgent.dids.config.addResolver(didRegistry) + bobAgent.dids.config.addRegistrar(didRegistry) + bobAgent.dids.config.addResolver(didRegistry) + + const didRouting = await aliceAgent.mediationRecipient.getRouting({}) + const did = `did:inmemory:${uuid()}` + const didDocument = createPeerDidDocumentFromServices([ + { + id: 'didcomm', + recipientKeys: [didRouting.recipientKey], + routingKeys: didRouting.routingKeys, + serviceEndpoint: didRouting.endpoints[0], + }, + ]) + didDocument.id = did + + await aliceAgent.dids.create({ + did, + didDocument, + }) + + // Do did rotate + await aliceAgent.connections.rotate({ connectionId: aliceBobConnection!.id, toDid: did }) + + // Wait for acknowledge + await waitForAgentMessageProcessedEvent(aliceAgent, { messageType: DidRotateAckMessage.type.messageTypeUri }) + + // Send message to previous did + await bobAgent.dependencyManager.resolve(MessageSender).sendMessage(messageToPreviousDid) + + await waitForBasicMessage(aliceAgent, { + content: 'Message to previous did', + connectionId: aliceBobConnection!.id, + }) + }) + + test('Rotate failed and send messages to previous did afterwards', async () => { + // Send message to initial did + await bobAgent.basicMessages.sendMessage(bobAliceConnection!.id, 'Hello initial did') + + await waitForBasicMessage(aliceAgent, { content: 'Hello initial did' }) + + const messageToPreviousDid = await getOutboundMessageContext(bobAgent.context, { + message: new BasicMessage({ content: 'Message to previous did' }), + connectionRecord: bobAliceConnection, + }) + + // Create a new external did + + // Use custom registry only for Alice agent, in order to force an error on Bob side + const didRegistry = new InMemoryDidRegistry() + aliceAgent.dids.config.addRegistrar(didRegistry) + aliceAgent.dids.config.addResolver(didRegistry) + + const didRouting = await aliceAgent.mediationRecipient.getRouting({}) + const did = `did:inmemory:${uuid()}` + const didDocument = createPeerDidDocumentFromServices([ + { + id: 'didcomm', + recipientKeys: [didRouting.recipientKey], + routingKeys: didRouting.routingKeys, + serviceEndpoint: didRouting.endpoints[0], + }, + ]) + didDocument.id = did + + await aliceAgent.dids.create({ + did, + didDocument, + }) + + // Do did rotate + await aliceAgent.connections.rotate({ connectionId: aliceBobConnection!.id, toDid: did }) + + // Wait for a problem report + await waitForAgentMessageProcessedEvent(aliceAgent, { + messageType: DidRotateProblemReportMessage.type.messageTypeUri, + }) + + // Send message to previous did + await bobAgent.dependencyManager.resolve(MessageSender).sendMessage(messageToPreviousDid) + + await waitForBasicMessage(aliceAgent, { + content: 'Message to previous did', + connectionId: aliceBobConnection!.id, + }) + + // Send message to stored did (should be the previous one) + await bobAgent.basicMessages.sendMessage(bobAliceConnection!.id, 'Message after did rotation failure') + + await waitForBasicMessage(aliceAgent, { + content: 'Message after did rotation failure', + connectionId: aliceBobConnection!.id, + }) + }) + }) + + describe('Hangup', () => { + test('Hangup without record deletion', async () => { + // Send message to initial did + await bobAgent.basicMessages.sendMessage(bobAliceConnection!.id, 'Hello initial did') + + await waitForBasicMessage(aliceAgent, { content: 'Hello initial did' }) + + // Store an outbound context so we can attempt to send a message even if the connection is terminated. + // A bit hacky, but may happen in some cases where message retry mechanisms are being used + const messageBeforeHangup = await getOutboundMessageContext(bobAgent.context, { + message: new BasicMessage({ content: 'Message before hangup' }), + connectionRecord: bobAliceConnection!.clone(), + }) + + await aliceAgent.connections.hangup({ connectionId: aliceBobConnection!.id }) + + // Wait for hangup + await waitForAgentMessageProcessedEvent(bobAgent, { + messageType: HangupMessage.type.messageTypeUri, + }) + + // If Bob attempts to send a message to Alice after they received the hangup, framework should reject it + expect(bobAgent.basicMessages.sendMessage(bobAliceConnection!.id, 'Message after hangup')).rejects.toThrowError() + + // If Bob sends a message afterwards, Alice should still be able to receive it + await bobAgent.dependencyManager.resolve(MessageSender).sendMessage(messageBeforeHangup) + + await waitForBasicMessage(aliceAgent, { + content: 'Message before hangup', + connectionId: aliceBobConnection!.id, + }) + }) + + test('Hangup and delete connection record', async () => { + // Send message to initial did + await bobAgent.basicMessages.sendMessage(bobAliceConnection!.id, 'Hello initial did') + + await waitForBasicMessage(aliceAgent, { content: 'Hello initial did' }) + + // Store an outbound context so we can attempt to send a message even if the connection is terminated. + // A bit hacky, but may happen in some cases where message retry mechanisms are being used + const messageBeforeHangup = await getOutboundMessageContext(bobAgent.context, { + message: new BasicMessage({ content: 'Message before hangup' }), + connectionRecord: bobAliceConnection!.clone(), + }) + + await aliceAgent.connections.hangup({ connectionId: aliceBobConnection!.id, deleteAfterHangup: true }) + + // Verify that alice connection has been effectively deleted + expect(aliceAgent.connections.getById(aliceBobConnection!.id)).rejects.toThrowError(RecordNotFoundError) + + // Wait for hangup + await waitForAgentMessageProcessedEvent(bobAgent, { + messageType: HangupMessage.type.messageTypeUri, + }) + + // If Bob sends a message afterwards, Alice should not receive it since the connection has been deleted + await bobAgent.dependencyManager.resolve(MessageSender).sendMessage(messageBeforeHangup) + + // An error is thrown by Alice agent and, after inspecting all basic messages, it cannot be found + // TODO: Update as soon as agent sends error events upon reception of messages + const observable = aliceAgent.events.observable('AgentReceiveMessageError') + const subject = new ReplaySubject(1) + observable.pipe(first(), timeout({ first: 10000 })).subscribe(subject) + await firstValueFrom(subject) + + const aliceBasicMessages = await aliceAgent.basicMessages.findAllByQuery({}) + expect(aliceBasicMessages.find((message) => message.content === 'Message before hangup')).toBeUndefined() + }) + }) +}) diff --git a/packages/core/src/modules/connections/__tests__/didexchange-numalgo.e2e.test.ts b/packages/core/src/modules/connections/__tests__/didexchange-numalgo.e2e.test.ts index 0c6c1c657b..50cf2742ee 100644 --- a/packages/core/src/modules/connections/__tests__/didexchange-numalgo.e2e.test.ts +++ b/packages/core/src/modules/connections/__tests__/didexchange-numalgo.e2e.test.ts @@ -74,7 +74,7 @@ describe('Did Exchange numalgo settings', () => { await didExchangeNumAlgoBaseTest({ requesterNumAlgoSetting: PeerDidNumAlgo.ShortFormAndLongForm }) }) - test.only('Connect using numalgo 4 for both requester and responder', async () => { + test('Connect using numalgo 4 for both requester and responder', async () => { await didExchangeNumAlgoBaseTest({ requesterNumAlgoSetting: PeerDidNumAlgo.ShortFormAndLongForm, responderNumAlgoSetting: PeerDidNumAlgo.ShortFormAndLongForm, @@ -143,20 +143,19 @@ async function didExchangeNumAlgoBaseTest(options: { let ourDid, routing if (options.createExternalDidForRequester) { // Create did externally - const routing = await aliceAgent.mediationRecipient.getRouting({}) - const ourDid = `did:inmemory:${uuid()}` + const didRouting = await aliceAgent.mediationRecipient.getRouting({}) + ourDid = `did:inmemory:${uuid()}` const didDocument = createPeerDidDocumentFromServices([ { id: 'didcomm', - recipientKeys: [routing.recipientKey], - routingKeys: routing.routingKeys, - serviceEndpoint: routing.endpoints[0], + recipientKeys: [didRouting.recipientKey], + routingKeys: didRouting.routingKeys, + serviceEndpoint: didRouting.endpoints[0], }, ]) didDocument.id = ourDid await aliceAgent.dids.create({ - method: 'inmemory', did: ourDid, didDocument, }) diff --git a/packages/core/src/modules/connections/handlers/DidRotateAckHandler.ts b/packages/core/src/modules/connections/handlers/DidRotateAckHandler.ts new file mode 100644 index 0000000000..ec05c71e8e --- /dev/null +++ b/packages/core/src/modules/connections/handlers/DidRotateAckHandler.ts @@ -0,0 +1,17 @@ +import type { MessageHandler, MessageHandlerInboundMessage } from '../../../agent/MessageHandler' +import type { DidRotateService } from '../services' + +import { DidRotateAckMessage } from '../messages' + +export class DidRotateAckHandler implements MessageHandler { + private didRotateService: DidRotateService + public supportedMessages = [DidRotateAckMessage] + + public constructor(didRotateService: DidRotateService) { + this.didRotateService = didRotateService + } + + public async handle(inboundMessage: MessageHandlerInboundMessage) { + await this.didRotateService.processRotateAck(inboundMessage) + } +} diff --git a/packages/core/src/modules/connections/handlers/DidRotateHandler.ts b/packages/core/src/modules/connections/handlers/DidRotateHandler.ts new file mode 100644 index 0000000000..9533253572 --- /dev/null +++ b/packages/core/src/modules/connections/handlers/DidRotateHandler.ts @@ -0,0 +1,26 @@ +import type { MessageHandler, MessageHandlerInboundMessage } from '../../../agent/MessageHandler' +import type { DidRotateService } from '../services' +import type { ConnectionService } from '../services/ConnectionService' + +import { AriesFrameworkError } from '../../../error' +import { DidRotateMessage } from '../messages' + +export class DidRotateHandler implements MessageHandler { + private didRotateService: DidRotateService + private connectionService: ConnectionService + public supportedMessages = [DidRotateMessage] + + public constructor(didRotateService: DidRotateService, connectionService: ConnectionService) { + this.didRotateService = didRotateService + this.connectionService = connectionService + } + + public async handle(messageContext: MessageHandlerInboundMessage) { + const { connection, recipientKey } = messageContext + if (!connection) { + throw new AriesFrameworkError(`Connection for verkey ${recipientKey?.fingerprint} not found!`) + } + + return this.didRotateService.processRotate(messageContext) + } +} diff --git a/packages/core/src/modules/connections/handlers/DidRotateProblemReportHandler.ts b/packages/core/src/modules/connections/handlers/DidRotateProblemReportHandler.ts new file mode 100644 index 0000000000..2f68e748bd --- /dev/null +++ b/packages/core/src/modules/connections/handlers/DidRotateProblemReportHandler.ts @@ -0,0 +1,17 @@ +import type { MessageHandler, MessageHandlerInboundMessage } from '../../../agent/MessageHandler' +import type { DidRotateService } from '../services' + +import { DidRotateProblemReportMessage } from '../messages' + +export class DidRotateProblemReportHandler implements MessageHandler { + private didRotateService: DidRotateService + public supportedMessages = [DidRotateProblemReportMessage] + + public constructor(didRotateService: DidRotateService) { + this.didRotateService = didRotateService + } + + public async handle(messageContext: MessageHandlerInboundMessage) { + await this.didRotateService.processProblemReport(messageContext) + } +} diff --git a/packages/core/src/modules/connections/handlers/HangupHandler.ts b/packages/core/src/modules/connections/handlers/HangupHandler.ts new file mode 100644 index 0000000000..5e66ee2944 --- /dev/null +++ b/packages/core/src/modules/connections/handlers/HangupHandler.ts @@ -0,0 +1,17 @@ +import type { MessageHandler, MessageHandlerInboundMessage } from '../../../agent/MessageHandler' +import type { DidRotateService } from '../services' + +import { HangupMessage } from '../messages' + +export class HangupHandler implements MessageHandler { + private didRotateService: DidRotateService + public supportedMessages = [HangupMessage] + + public constructor(didRotateService: DidRotateService) { + this.didRotateService = didRotateService + } + + public async handle(inboundMessage: MessageHandlerInboundMessage) { + await this.didRotateService.processHangup(inboundMessage) + } +} diff --git a/packages/core/src/modules/connections/handlers/index.ts b/packages/core/src/modules/connections/handlers/index.ts index edd1a26766..0aeb955bdc 100644 --- a/packages/core/src/modules/connections/handlers/index.ts +++ b/packages/core/src/modules/connections/handlers/index.ts @@ -7,3 +7,7 @@ export * from './DidExchangeRequestHandler' export * from './DidExchangeResponseHandler' export * from './DidExchangeCompleteHandler' export * from './ConnectionProblemReportHandler' +export * from './DidRotateHandler' +export * from './DidRotateAckHandler' +export * from './DidRotateProblemReportHandler' +export * from './HangupHandler' diff --git a/packages/core/src/modules/connections/messages/DidRotateAckMessage.ts b/packages/core/src/modules/connections/messages/DidRotateAckMessage.ts new file mode 100644 index 0000000000..363c7b1e45 --- /dev/null +++ b/packages/core/src/modules/connections/messages/DidRotateAckMessage.ts @@ -0,0 +1,20 @@ +import type { AckMessageOptions } from '../../common/messages/AckMessage' + +import { IsValidMessageType, parseMessageType } from '../../../utils/messageType' +import { AckMessage } from '../../common/messages/AckMessage' + +export type DidRotateAckMessageOptions = AckMessageOptions + +export class DidRotateAckMessage extends AckMessage { + /** + * Create new CredentialAckMessage instance. + * @param options + */ + public constructor(options: DidRotateAckMessageOptions) { + super(options) + } + + @IsValidMessageType(DidRotateAckMessage.type) + public readonly type = DidRotateAckMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/did-rotate/1.0/ack') +} diff --git a/packages/core/src/modules/connections/messages/DidRotateMessage.ts b/packages/core/src/modules/connections/messages/DidRotateMessage.ts new file mode 100644 index 0000000000..a33db94a71 --- /dev/null +++ b/packages/core/src/modules/connections/messages/DidRotateMessage.ts @@ -0,0 +1,38 @@ +import { Expose } from 'class-transformer' +import { IsString } from 'class-validator' + +import { AgentMessage } from '../../../agent/AgentMessage' +import { IsValidMessageType, parseMessageType } from '../../../utils/messageType' + +export interface DidRotateMessageOptions { + id?: string + toDid: string +} + +/** + * Message to communicate the DID a party wish to rotate to. + * + * @see https://github.com/hyperledger/aries-rfcs/tree/main/features/0794-did-rotate#rotate + */ +export class DidRotateMessage extends AgentMessage { + /** + * Create new RotateMessage instance. + * @param options + */ + public constructor(options: DidRotateMessageOptions) { + super() + + if (options) { + this.id = options.id || this.generateId() + this.toDid = options.toDid + } + } + + @IsValidMessageType(DidRotateMessage.type) + public readonly type = DidRotateMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/did-rotate/1.0/rotate') + + @Expose({ name: 'to_did' }) + @IsString() + public readonly toDid!: string +} diff --git a/packages/core/src/modules/connections/messages/DidRotateProblemReportMessage.ts b/packages/core/src/modules/connections/messages/DidRotateProblemReportMessage.ts new file mode 100644 index 0000000000..2eaf0c027d --- /dev/null +++ b/packages/core/src/modules/connections/messages/DidRotateProblemReportMessage.ts @@ -0,0 +1,19 @@ +import type { ProblemReportMessageOptions } from '../../problem-reports/messages/ProblemReportMessage' + +import { IsValidMessageType, parseMessageType } from '../../../utils/messageType' +import { ProblemReportMessage } from '../../problem-reports/messages/ProblemReportMessage' + +export type DidRotateProblemReportMessageOptions = ProblemReportMessageOptions + +/** + * @see https://github.com/hyperledger/aries-rfcs/blob/main/features/0035-report-problem/README.md + */ +export class DidRotateProblemReportMessage extends ProblemReportMessage { + public constructor(options: DidRotateProblemReportMessageOptions) { + super(options) + } + + @IsValidMessageType(DidRotateProblemReportMessage.type) + public readonly type = DidRotateProblemReportMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/did-rotate/1.0/problem-report') +} diff --git a/packages/core/src/modules/connections/messages/HangupMessage.ts b/packages/core/src/modules/connections/messages/HangupMessage.ts new file mode 100644 index 0000000000..b9ab2bd510 --- /dev/null +++ b/packages/core/src/modules/connections/messages/HangupMessage.ts @@ -0,0 +1,30 @@ +import { AgentMessage } from '../../../agent/AgentMessage' +import { IsValidMessageType, parseMessageType } from '../../../utils/messageType' + +export interface HangupMessageOptions { + id?: string +} + +/** + * This message is sent by the rotating_party to inform the observing_party that they are done + * with the relationship and will no longer be responding. + * + * @see https://github.com/hyperledger/aries-rfcs/tree/main/features/0794-did-rotate#hangup + */ +export class HangupMessage extends AgentMessage { + /** + * Create new HangupMessage instance. + * @param options + */ + public constructor(options: HangupMessageOptions) { + super() + + if (options) { + this.id = options.id || this.generateId() + } + } + + @IsValidMessageType(HangupMessage.type) + public readonly type = HangupMessage.type.messageTypeUri + public static readonly type = parseMessageType('https://didcomm.org/did-rotate/1.0/hangup') +} diff --git a/packages/core/src/modules/connections/messages/index.ts b/packages/core/src/modules/connections/messages/index.ts index 7507e5ed56..1a3acdf7c8 100644 --- a/packages/core/src/modules/connections/messages/index.ts +++ b/packages/core/src/modules/connections/messages/index.ts @@ -8,3 +8,7 @@ export * from './DidExchangeRequestMessage' export * from './DidExchangeResponseMessage' export * from './DidExchangeCompleteMessage' export * from './DidExchangeProblemReportMessage' +export * from './DidRotateProblemReportMessage' +export * from './DidRotateMessage' +export * from './DidRotateAckMessage' +export * from './HangupMessage' diff --git a/packages/core/src/modules/connections/models/DidRotateRole.ts b/packages/core/src/modules/connections/models/DidRotateRole.ts new file mode 100644 index 0000000000..310c124ed4 --- /dev/null +++ b/packages/core/src/modules/connections/models/DidRotateRole.ts @@ -0,0 +1,4 @@ +export enum DidRotateRole { + RotatingParty = 'rotating_party', + ObservingParty = 'observing_party', +} diff --git a/packages/core/src/modules/connections/models/index.ts b/packages/core/src/modules/connections/models/index.ts index 69752df9c7..72a4635768 100644 --- a/packages/core/src/modules/connections/models/index.ts +++ b/packages/core/src/modules/connections/models/index.ts @@ -3,6 +3,7 @@ export * from './ConnectionRole' export * from './ConnectionState' export * from './DidExchangeState' export * from './DidExchangeRole' +export * from './DidRotateRole' export * from './HandshakeProtocol' export * from './did' export * from './ConnectionType' diff --git a/packages/core/src/modules/connections/repository/ConnectionMetadataTypes.ts b/packages/core/src/modules/connections/repository/ConnectionMetadataTypes.ts index 9609097515..f16ea2d043 100644 --- a/packages/core/src/modules/connections/repository/ConnectionMetadataTypes.ts +++ b/packages/core/src/modules/connections/repository/ConnectionMetadataTypes.ts @@ -1,9 +1,15 @@ export enum ConnectionMetadataKeys { UseDidKeysForProtocol = '_internal/useDidKeysForProtocol', + DidRotate = '_internal/didRotate', } export type ConnectionMetadata = { [ConnectionMetadataKeys.UseDidKeysForProtocol]: { [protocolUri: string]: boolean } + [ConnectionMetadataKeys.DidRotate]: { + did: string + threadId: string + mediatorId?: string + } } diff --git a/packages/core/src/modules/connections/repository/ConnectionRecord.ts b/packages/core/src/modules/connections/repository/ConnectionRecord.ts index f05106e5c9..2f0fa23059 100644 --- a/packages/core/src/modules/connections/repository/ConnectionRecord.ts +++ b/packages/core/src/modules/connections/repository/ConnectionRecord.ts @@ -27,6 +27,8 @@ export interface ConnectionRecordProps { outOfBandId?: string invitationDid?: string connectionTypes?: Array + previousDids?: Array + previousTheirDids?: Array } export type CustomConnectionTags = TagsBase @@ -40,6 +42,8 @@ export type DefaultConnectionTags = { outOfBandId?: string invitationDid?: string connectionTypes?: Array + previousDids?: Array + previousTheirDids?: Array } export class ConnectionRecord @@ -66,6 +70,8 @@ export class ConnectionRecord public invitationDid?: string public connectionTypes: string[] = [] + public previousDids: string[] = [] + public previousTheirDids: string[] = [] public static readonly type = 'ConnectionRecord' public readonly type = ConnectionRecord.type @@ -92,6 +98,8 @@ export class ConnectionRecord this.protocol = props.protocol this.outOfBandId = props.outOfBandId this.connectionTypes = props.connectionTypes ?? [] + this.previousDids = props.previousDids ?? [] + this.previousTheirDids = props.previousTheirDids ?? [] } } @@ -107,6 +115,8 @@ export class ConnectionRecord outOfBandId: this.outOfBandId, invitationDid: this.invitationDid, connectionTypes: this.connectionTypes, + previousDids: this.previousDids, + previousTheirDids: this.previousTheirDids, } } diff --git a/packages/core/src/modules/connections/repository/ConnectionRepository.ts b/packages/core/src/modules/connections/repository/ConnectionRepository.ts index 071d7e90cb..158c75e7a8 100644 --- a/packages/core/src/modules/connections/repository/ConnectionRepository.ts +++ b/packages/core/src/modules/connections/repository/ConnectionRepository.ts @@ -20,8 +20,14 @@ export class ConnectionRepository extends Repository { public async findByDids(agentContext: AgentContext, { ourDid, theirDid }: { ourDid: string; theirDid: string }) { return this.findSingleByQuery(agentContext, { - did: ourDid, - theirDid, + $or: [ + { + did: ourDid, + theirDid, + }, + { did: ourDid, previousTheirDids: [theirDid] }, + { previousDids: [ourDid], theirDid }, + ], }) } diff --git a/packages/core/src/modules/connections/repository/__tests__/ConnectionRecord.test.ts b/packages/core/src/modules/connections/repository/__tests__/ConnectionRecord.test.ts index 229a99d1e6..e052bfc594 100644 --- a/packages/core/src/modules/connections/repository/__tests__/ConnectionRecord.test.ts +++ b/packages/core/src/modules/connections/repository/__tests__/ConnectionRecord.test.ts @@ -25,6 +25,8 @@ describe('ConnectionRecord', () => { outOfBandId: 'a-out-of-band-id', invitationDid: 'a-invitation-did', connectionTypes: [], + previousDids: [], + previousTheirDids: [], }) }) }) diff --git a/packages/core/src/modules/connections/services/DidRotateService.ts b/packages/core/src/modules/connections/services/DidRotateService.ts new file mode 100644 index 0000000000..23b9a5b7d5 --- /dev/null +++ b/packages/core/src/modules/connections/services/DidRotateService.ts @@ -0,0 +1,276 @@ +import type { Routing } from './ConnectionService' +import type { AgentContext } from '../../../agent' +import type { InboundMessageContext } from '../../../agent/models/InboundMessageContext' +import type { ConnectionRecord } from '../repository/ConnectionRecord' + +import { OutboundMessageContext } from '../../../agent/models' +import { InjectionSymbols } from '../../../constants' +import { AriesFrameworkError } from '../../../error' +import { Logger } from '../../../logger' +import { inject, injectable } from '../../../plugins' +import { AckStatus } from '../../common' +import { + DidRepository, + DidResolverService, + PeerDidNumAlgo, + getAlternativeDidsForPeerDid, + getNumAlgoFromPeerDid, + isValidPeerDid, +} from '../../dids' +import { getMediationRecordForDidDocument } from '../../routing/services/helpers' +import { ConnectionsModuleConfig } from '../ConnectionsModuleConfig' +import { DidRotateMessage, DidRotateAckMessage, DidRotateProblemReportMessage, HangupMessage } from '../messages' +import { ConnectionMetadataKeys } from '../repository/ConnectionMetadataTypes' + +import { ConnectionService } from './ConnectionService' +import { createPeerDidFromServices, getDidDocumentForCreatedDid, routingToServices } from './helpers' + +@injectable() +export class DidRotateService { + private didResolverService: DidResolverService + private logger: Logger + + public constructor(didResolverService: DidResolverService, @inject(InjectionSymbols.Logger) logger: Logger) { + this.didResolverService = didResolverService + this.logger = logger + } + + public async createRotate( + agentContext: AgentContext, + options: { connection: ConnectionRecord; toDid?: string; routing?: Routing } + ) { + const { connection, toDid, routing } = options + + const config = agentContext.dependencyManager.resolve(ConnectionsModuleConfig) + + // Do not allow to receive concurrent did rotation flows + const didRotateMetadata = connection.metadata.get(ConnectionMetadataKeys.DidRotate) + + if (didRotateMetadata) { + throw new AriesFrameworkError( + `There is already an existing opened did rotation flow for connection id ${connection.id}` + ) + } + + let didDocument, mediatorId + // If did is specified, make sure we have all key material for it + if (toDid) { + didDocument = await getDidDocumentForCreatedDid(agentContext, toDid) + mediatorId = (await getMediationRecordForDidDocument(agentContext, didDocument))?.id + + // Otherwise, create a did:peer based on the provided routing + } else { + if (!routing) { + throw new AriesFrameworkError('Routing configuration must be defined when rotating to a new peer did') + } + + didDocument = await createPeerDidFromServices( + agentContext, + routingToServices(routing), + config.peerNumAlgoForDidRotation + ) + mediatorId = routing.mediatorId + } + + const message = new DidRotateMessage({ toDid: didDocument.id }) + + // We set new info into connection metadata for further 'sealing' it once we receive an acknowledge + // All messages sent in-between will be using previous connection information + connection.metadata.set(ConnectionMetadataKeys.DidRotate, { + threadId: message.threadId, + did: didDocument.id, + mediatorId, + }) + + await agentContext.dependencyManager.resolve(ConnectionService).update(agentContext, connection) + + return message + } + + public async createHangup(agentContext: AgentContext, options: { connection: ConnectionRecord }) { + const { connection } = options + + const message = new HangupMessage({}) + + // Remove did to indicate termination status for this connection + if (connection.did) { + connection.previousDids = [...connection.previousDids, connection.did] + } + + connection.did = undefined + + await agentContext.dependencyManager.resolve(ConnectionService).update(agentContext, connection) + + return message + } + + /** + * Process a Hangup message and mark connection's theirDid as undefined so it is effectively terminated. + * Connection Record itself is not deleted (TODO: config parameter to automatically do so) + * + * Its previous did will be stored in record in order to be able to recognize any message received + * afterwards. + * + * @param messageContext + */ + public async processHangup(messageContext: InboundMessageContext) { + const connection = messageContext.assertReadyConnection() + const { agentContext } = messageContext + + if (connection.theirDid) { + connection.previousTheirDids = [...connection.previousTheirDids, connection.theirDid] + } + + connection.theirDid = undefined + + await agentContext.dependencyManager.resolve(ConnectionService).update(agentContext, connection) + } + + /** + * Process an incoming DID Rotate message and update connection if success. Any acknowledge + * or problem report will be sent to the prior DID, so the created context will take former + * connection record data + * + * @param param + * @param connection + * @returns + */ + public async processRotate(messageContext: InboundMessageContext) { + const connection = messageContext.assertReadyConnection() + const { message, agentContext } = messageContext + + // Check and store their new did + const newDid = message.toDid + + // DID Rotation not supported for peer:1 dids, as we need explicit did document information + if (isValidPeerDid(newDid) && getNumAlgoFromPeerDid(newDid) === PeerDidNumAlgo.GenesisDoc) { + this.logger.error(`Unable to resolve DID Document for '${newDid}`) + + const response = new DidRotateProblemReportMessage({ + description: { en: 'DID Method Unsupported', code: 'e.did.method_unsupported' }, + }) + return new OutboundMessageContext(response, { agentContext, connection }) + } + + const didDocument = (await this.didResolverService.resolve(agentContext, newDid)).didDocument + + // Cannot resolve did + if (!didDocument) { + this.logger.error(`Unable to resolve DID Document for '${newDid}`) + + const response = new DidRotateProblemReportMessage({ + description: { en: 'DID Unresolvable', code: 'e.did.unresolvable' }, + }) + return new OutboundMessageContext(response, { agentContext, connection }) + } + + // Did is resolved but no compatible DIDComm services found + if (!didDocument.didCommServices) { + const response = new DidRotateProblemReportMessage({ + description: { en: 'DID Document Unsupported', code: 'e.did.doc_unsupported' }, + }) + return new OutboundMessageContext(response, { agentContext, connection }) + } + + // Send acknowledge to previous did and persist new did. Previous did will be stored in connection record in + // order to still accept messages from it + const outboundMessageContext = new OutboundMessageContext( + new DidRotateAckMessage({ + threadId: message.threadId, + status: AckStatus.OK, + }), + { agentContext, connection: connection.clone() } + ) + + // Store received did and update connection for further message processing + await agentContext.dependencyManager.resolve(DidRepository).storeReceivedDid(agentContext, { + did: didDocument.id, + didDocument, + tags: { + recipientKeyFingerprints: didDocument.recipientKeys.map((key) => key.fingerprint), + + // For did:peer, store any alternative dids (like short form did:peer:4), + // it may have in order to relate any message referencing it + alternativeDids: isValidPeerDid(didDocument.id) ? getAlternativeDidsForPeerDid(didDocument.id) : undefined, + }, + }) + + if (connection.theirDid) { + connection.previousTheirDids = [...connection.previousTheirDids, connection.theirDid] + } + + connection.theirDid = newDid + + await agentContext.dependencyManager.resolve(ConnectionService).update(agentContext, connection) + + return outboundMessageContext + } + + public async processRotateAck(inboundMessage: InboundMessageContext) { + const { agentContext, message } = inboundMessage + + const connection = inboundMessage.assertReadyConnection() + + // Update connection info based on metadata set when creating the rotate message + const didRotateMetadata = connection.metadata.get(ConnectionMetadataKeys.DidRotate) + + if (!didRotateMetadata) { + throw new AriesFrameworkError(`No did rotation data found for connection with id '${connection.id}'`) + } + + if (didRotateMetadata.threadId !== message.threadId) { + throw new AriesFrameworkError( + `Existing did rotation flow thread id '${didRotateMetadata.threadId} does not match incoming message'` + ) + } + + // Store previous did in order to still accept out-of-order messages that arrived later using it + if (connection.did) connection.previousDids = [...connection.previousDids, connection.did] + + connection.did = didRotateMetadata.did + connection.mediatorId = didRotateMetadata.mediatorId + connection.metadata.delete(ConnectionMetadataKeys.DidRotate) + + await agentContext.dependencyManager.resolve(ConnectionService).update(agentContext, connection) + } + + /** + * Process a problem report related to did rotate protocol, by simply deleting any temporary metadata. + * + * No specific event is thrown other than generic message processing + * + * @param messageContext + */ + public async processProblemReport( + messageContext: InboundMessageContext + ): Promise { + const { message, agentContext } = messageContext + + const connection = messageContext.assertReadyConnection() + + this.logger.debug(`Processing problem report with id ${message.id}`) + + // Delete any existing did rotation metadata in order to 'reset' the connection + const didRotateMetadata = connection.metadata.get(ConnectionMetadataKeys.DidRotate) + + if (!didRotateMetadata) { + throw new AriesFrameworkError(`No did rotation data found for connection with id '${connection.id}'`) + } + + connection.metadata.delete(ConnectionMetadataKeys.DidRotate) + + await agentContext.dependencyManager.resolve(ConnectionService).update(agentContext, connection) + } + + public async clearDidRotationData(agentContext: AgentContext, connection: ConnectionRecord) { + const didRotateMetadata = connection.metadata.get(ConnectionMetadataKeys.DidRotate) + + if (!didRotateMetadata) { + throw new AriesFrameworkError(`No did rotation data found for connection with id '${connection.id}'`) + } + + connection.metadata.delete(ConnectionMetadataKeys.DidRotate) + + await agentContext.dependencyManager.resolve(ConnectionService).update(agentContext, connection) + } +} diff --git a/packages/core/src/modules/connections/services/helpers.ts b/packages/core/src/modules/connections/services/helpers.ts index 3dbcc9c837..e0dc91a93a 100644 --- a/packages/core/src/modules/connections/services/helpers.ts +++ b/packages/core/src/modules/connections/services/helpers.ts @@ -1,9 +1,20 @@ -import type { DidDocument } from '../../dids' +import type { Routing } from './ConnectionService' +import type { AgentContext } from '../../../agent' +import type { ResolvedDidCommService } from '../../didcomm' +import type { DidDocument, PeerDidNumAlgo } from '../../dids' import type { DidDoc, PublicKey } from '../models' import { Key, KeyType } from '../../../crypto' import { AriesFrameworkError } from '../../../error' -import { IndyAgentService, DidCommV1Service, DidDocumentBuilder, getEd25519VerificationKey2018 } from '../../dids' +import { + IndyAgentService, + DidCommV1Service, + DidDocumentBuilder, + getEd25519VerificationKey2018, + DidRepository, + DidsApi, + createPeerDidDocumentFromServices, +} from '../../dids' import { didDocumentJsonToNumAlgo1Did } from '../../dids/methods/peer/peerDidNumAlgo1' import { EmbeddedAuthentication } from '../models' @@ -109,3 +120,47 @@ function convertPublicKeyToVerificationMethod(publicKey: PublicKey) { controller: '#id', }) } + +export function routingToServices(routing: Routing): ResolvedDidCommService[] { + return routing.endpoints.map((endpoint, index) => ({ + id: `#inline-${index}`, + serviceEndpoint: endpoint, + recipientKeys: [routing.recipientKey], + routingKeys: routing.routingKeys, + })) +} + +export async function getDidDocumentForCreatedDid(agentContext: AgentContext, did: string) { + const didRecord = await agentContext.dependencyManager.resolve(DidRepository).findCreatedDid(agentContext, did) + + if (!didRecord?.didDocument) { + throw new AriesFrameworkError(`Could not get DidDocument for created did ${did}`) + } + return didRecord.didDocument +} + +export async function createPeerDidFromServices( + agentContext: AgentContext, + services: ResolvedDidCommService[], + numAlgo: PeerDidNumAlgo +) { + const didsApi = agentContext.dependencyManager.resolve(DidsApi) + + // Create did document without the id property + const didDocument = createPeerDidDocumentFromServices(services) + // Register did:peer document. This will generate the id property and save it to a did record + + const result = await didsApi.create({ + method: 'peer', + didDocument, + options: { + numAlgo, + }, + }) + + if (result.didState?.state !== 'finished') { + throw new AriesFrameworkError(`Did document creation failed: ${JSON.stringify(result.didState)}`) + } + + return result.didState.didDocument +} diff --git a/packages/core/src/modules/connections/services/index.ts b/packages/core/src/modules/connections/services/index.ts index 3520b11146..db9fe13ac4 100644 --- a/packages/core/src/modules/connections/services/index.ts +++ b/packages/core/src/modules/connections/services/index.ts @@ -1,2 +1,3 @@ export * from './ConnectionService' +export * from './DidRotateService' export * from './TrustPingService' diff --git a/packages/core/src/modules/routing/services/helpers.ts b/packages/core/src/modules/routing/services/helpers.ts new file mode 100644 index 0000000000..0a3bb4fe42 --- /dev/null +++ b/packages/core/src/modules/routing/services/helpers.ts @@ -0,0 +1,13 @@ +import type { AgentContext } from '../../../agent' +import type { DidDocument } from '../../dids' + +import { MediationRecipientService } from './MediationRecipientService' + +export async function getMediationRecordForDidDocument(agentContext: AgentContext, didDocument: DidDocument) { + const [mediatorRecord] = await agentContext.dependencyManager + .resolve(MediationRecipientService) + .findAllMediatorsByQuery(agentContext, { + recipientKeys: didDocument.recipientKeys.map((key) => key.publicKeyBase58), + }) + return mediatorRecord +} diff --git a/packages/core/src/storage/migration/__tests__/__snapshots__/0.1.test.ts.snap b/packages/core/src/storage/migration/__tests__/__snapshots__/0.1.test.ts.snap index 83449887ab..c06c6515c6 100644 --- a/packages/core/src/storage/migration/__tests__/__snapshots__/0.1.test.ts.snap +++ b/packages/core/src/storage/migration/__tests__/__snapshots__/0.1.test.ts.snap @@ -1256,6 +1256,8 @@ exports[`UpdateAssistant | v0.1 - v0.2 should correctly update the connection re "invitationKey": "EZdqDkqBSfiepgMZ15jsZvtxTwjiz5diBtjJBnvvMvQF", "mediatorId": undefined, "outOfBandId": "3-4e4f-41d9-94c4-f49351b811f1", + "previousDids": [], + "previousTheirDids": [], "role": "responder", "state": "request-received", "theirDid": "did:peer:1zQmc3BZoTinpVaG3oZ4PmRVN4JMdNZGCmPkS6smmTNLnvEZ", @@ -1273,6 +1275,8 @@ exports[`UpdateAssistant | v0.1 - v0.2 should correctly update the connection re "invitationDid": "did:peer:2.SeyJzIjoicnhqczphbGljZSIsInQiOiJkaWQtY29tbXVuaWNhdGlvbiIsInByaW9yaXR5IjowLCJyZWNpcGllbnRLZXlzIjpbImRpZDprZXk6ejZNa3QxdHNwMTVjbkREN3dCQ0ZnZWhpUjJTeEhYMWFQeHQ0c3VlRTI0dHdIOUJkI3o2TWt0MXRzcDE1Y25ERDd3QkNGZ2VoaVIyU3hIWDFhUHh0NHN1ZUUyNHR3SDlCZCJdLCJyIjpbXX0", "metadata": {}, "outOfBandId": "3-4e4f-41d9-94c4-f49351b811f1", + "previousDids": [], + "previousTheirDids": [], "role": "responder", "state": "request-received", "theirDid": "did:peer:1zQmc3BZoTinpVaG3oZ4PmRVN4JMdNZGCmPkS6smmTNLnvEZ", @@ -1290,6 +1294,8 @@ exports[`UpdateAssistant | v0.1 - v0.2 should correctly update the connection re "invitationKey": "2G8JohwyJtj69ruWFC3hBkdoaJW5eg31E66ohceVWCvy", "mediatorId": undefined, "outOfBandId": "1-4e4f-41d9-94c4-f49351b811f1", + "previousDids": [], + "previousTheirDids": [], "role": "requester", "state": "completed", "theirDid": "did:peer:1zQmeHpGaZ48DnAP2k3KntXB1vmd8MgLEdcb4EQzqWJDHcbX", @@ -1307,6 +1313,8 @@ exports[`UpdateAssistant | v0.1 - v0.2 should correctly update the connection re "invitationDid": "did:peer:2.SeyJzIjoicnhqczpmYWJlciIsInQiOiJkaWQtY29tbXVuaWNhdGlvbiIsInByaW9yaXR5IjowLCJyZWNpcGllbnRLZXlzIjpbImRpZDprZXk6ejZNa2ZpUE1QeENRZVNEWkdNa0N2bTFZMnJCb1BzbXc0WkhNdjcxalh0Y1dSUmlNI3o2TWtmaVBNUHhDUWVTRFpHTWtDdm0xWTJyQm9Qc213NFpITXY3MWpYdGNXUlJpTSJdLCJyIjpbXX0", "metadata": {}, "outOfBandId": "1-4e4f-41d9-94c4-f49351b811f1", + "previousDids": [], + "previousTheirDids": [], "role": "requester", "state": "completed", "theirDid": "did:peer:1zQmeHpGaZ48DnAP2k3KntXB1vmd8MgLEdcb4EQzqWJDHcbX", @@ -1324,6 +1332,8 @@ exports[`UpdateAssistant | v0.1 - v0.2 should correctly update the connection re "invitationKey": "EkJ7p82VB3a3AfuEWGS3gc1dPyY1BZ4PaVEztjwh1nVq", "mediatorId": undefined, "outOfBandId": "2-4e4f-41d9-94c4-f49351b811f1", + "previousDids": [], + "previousTheirDids": [], "role": "responder", "state": "completed", "theirDid": "did:peer:1zQmXYj3nNwsF37WXXdb8XkCAtsTCBpJJbsLKPPGfi2PWCTU", @@ -1340,6 +1350,8 @@ exports[`UpdateAssistant | v0.1 - v0.2 should correctly update the connection re "invitationDid": "did:peer:2.SeyJzIjoicnhqczphbGljZSIsInQiOiJkaWQtY29tbXVuaWNhdGlvbiIsInByaW9yaXR5IjowLCJyZWNpcGllbnRLZXlzIjpbImRpZDprZXk6ejZNa3RDWkFRTkd2V2I0V0hBandCcVB0WGhaZERZb3JiU0prR1c5dmoxdWh3MUhEI3o2TWt0Q1pBUU5HdldiNFdIQWp3QnFQdFhoWmREWW9yYlNKa0dXOXZqMXVodzFIRCJdLCJyIjpbXX0", "metadata": {}, "outOfBandId": "2-4e4f-41d9-94c4-f49351b811f1", + "previousDids": [], + "previousTheirDids": [], "role": "responder", "state": "completed", "theirDid": "did:peer:1zQmXYj3nNwsF37WXXdb8XkCAtsTCBpJJbsLKPPGfi2PWCTU", @@ -1369,6 +1381,8 @@ exports[`UpdateAssistant | v0.1 - v0.2 should correctly update the connection re "invitationKey": "G4CCB2mL9Fc32c9jqQKF9w9FUEfaaUzWvNdFVkEBSkQe", "mediatorId": undefined, "outOfBandId": "7-4e4f-41d9-94c4-f49351b811f1", + "previousDids": [], + "previousTheirDids": [], "role": "responder", "state": "invitation-sent", "theirDid": undefined, @@ -1385,6 +1399,8 @@ exports[`UpdateAssistant | v0.1 - v0.2 should correctly update the connection re "invitationDid": "did:peer:2.SeyJzIjoicnhqczphbGljZSIsInQiOiJkaWQtY29tbXVuaWNhdGlvbiIsInByaW9yaXR5IjowLCJyZWNpcGllbnRLZXlzIjpbImRpZDprZXk6ejZNa3VXVEVtSDFtVW82Vzk2elNXeUg2MTJoRkhvd1J6TkVzY1BZQkwyQ0NNeUMyI3o2TWt1V1RFbUgxbVVvNlc5NnpTV3lINjEyaEZIb3dSek5Fc2NQWUJMMkNDTXlDMiJdLCJyIjpbXX0", "metadata": {}, "outOfBandId": "7-4e4f-41d9-94c4-f49351b811f1", + "previousDids": [], + "previousTheirDids": [], "role": "responder", "state": "invitation-sent", "updatedAt": "2022-01-21T22:50:20.522Z", @@ -1399,6 +1415,8 @@ exports[`UpdateAssistant | v0.1 - v0.2 should correctly update the connection re "invitationKey": "9akAmyoFVow6cWTg2M4LSVTckqbrCjuS3fQpQ8Zrm2eS", "mediatorId": undefined, "outOfBandId": "6-4e4f-41d9-94c4-f49351b811f1", + "previousDids": [], + "previousTheirDids": [], "role": "responder", "state": "response-sent", "theirDid": "did:peer:1zQmadmBfngrYSWhYYxZ24fpW29iwhKhQ6CB6euLabbSK6ga", @@ -1416,6 +1434,8 @@ exports[`UpdateAssistant | v0.1 - v0.2 should correctly update the connection re "invitationDid": "did:peer:2.SeyJzIjoicnhqczphbGljZSIsInQiOiJkaWQtY29tbXVuaWNhdGlvbiIsInByaW9yaXR5IjowLCJyZWNpcGllbnRLZXlzIjpbImRpZDprZXk6ejZNa28zMURORTNncU1SWmoxSk5odjJCSGIxY2FRc2hjZDluamdLa0VRWHNnRlJwI3o2TWtvMzFETkUzZ3FNUlpqMUpOaHYyQkhiMWNhUXNoY2Q5bmpnS2tFUVhzZ0ZScCJdLCJyIjpbXX0", "metadata": {}, "outOfBandId": "6-4e4f-41d9-94c4-f49351b811f1", + "previousDids": [], + "previousTheirDids": [], "role": "responder", "state": "response-sent", "theirDid": "did:peer:1zQmadmBfngrYSWhYYxZ24fpW29iwhKhQ6CB6euLabbSK6ga", @@ -2395,6 +2415,8 @@ exports[`UpdateAssistant | v0.1 - v0.2 should correctly update the connection re "invitationKey": "5m3HUGs6wFndaEk51zTBXuFwZza2tnGj4NzT5EkUiWaU", "mediatorId": undefined, "outOfBandId": "5-4e4f-41d9-94c4-f49351b811f1", + "previousDids": [], + "previousTheirDids": [], "role": "requester", "state": "response-received", "theirDid": "did:peer:1zQmcXZepLE55VGCMELEFjMd4nKrzp3GGyRR3r3MYermagui", @@ -2412,6 +2434,8 @@ exports[`UpdateAssistant | v0.1 - v0.2 should correctly update the connection re "invitationDid": "did:peer:2.SeyJzIjoicnhqczpmYWJlciIsInQiOiJkaWQtY29tbXVuaWNhdGlvbiIsInByaW9yaXR5IjowLCJyZWNpcGllbnRLZXlzIjpbImRpZDprZXk6ejZNa2pESkw0WDdZR29INmdqYW1oWlIyTnpvd1BacXRKZlg1a1B1TnVXaVZkak1yI3o2TWtqREpMNFg3WUdvSDZnamFtaFpSMk56b3dQWnF0SmZYNWtQdU51V2lWZGpNciJdLCJyIjpbXX0", "metadata": {}, "outOfBandId": "5-4e4f-41d9-94c4-f49351b811f1", + "previousDids": [], + "previousTheirDids": [], "role": "requester", "state": "response-received", "theirDid": "did:peer:1zQmcXZepLE55VGCMELEFjMd4nKrzp3GGyRR3r3MYermagui", @@ -2429,6 +2453,8 @@ exports[`UpdateAssistant | v0.1 - v0.2 should correctly update the connection re "invitationKey": "8MN6LZnM8t1HmzMNw5Sp8kUVfQkFK1nCUMRSfQBoSNAC", "mediatorId": undefined, "outOfBandId": "4-4e4f-41d9-94c4-f49351b811f1", + "previousDids": [], + "previousTheirDids": [], "role": "requester", "state": "request-sent", "theirDid": undefined, @@ -2444,6 +2470,8 @@ exports[`UpdateAssistant | v0.1 - v0.2 should correctly update the connection re "invitationDid": "did:peer:2.SeyJzIjoicnhqczpmYWJlciIsInQiOiJkaWQtY29tbXVuaWNhdGlvbiIsInByaW9yaXR5IjowLCJyZWNpcGllbnRLZXlzIjpbImRpZDprZXk6ejZNa21vZDh2cDJuVVJWa3RWQzVjZVFleXIyVlV6MjZpdTJaQU5MTlZnOXBNYXdhI3o2TWttb2Q4dnAyblVSVmt0VkM1Y2VRZXlyMlZVejI2aXUyWkFOTE5WZzlwTWF3YSJdLCJyIjpbXX0", "metadata": {}, "outOfBandId": "4-4e4f-41d9-94c4-f49351b811f1", + "previousDids": [], + "previousTheirDids": [], "role": "requester", "state": "request-sent", "theirLabel": "Agent: PopulateWallet2", diff --git a/packages/core/src/storage/migration/updates/0.1-0.2/__tests__/connection.test.ts b/packages/core/src/storage/migration/updates/0.1-0.2/__tests__/connection.test.ts index 70c72c9c2a..af1886a094 100644 --- a/packages/core/src/storage/migration/updates/0.1-0.2/__tests__/connection.test.ts +++ b/packages/core/src/storage/migration/updates/0.1-0.2/__tests__/connection.test.ts @@ -142,6 +142,8 @@ describe('0.1-0.2 | Connection', () => { theirDid: didPeer4kgVt6CidfKgo1MoWMqsQX.id, outOfBandId: expect.any(String), connectionTypes: [], + previousDids: [], + previousTheirDids: [], }) }) }) @@ -173,6 +175,8 @@ describe('0.1-0.2 | Connection', () => { label: 'test', }, connectionTypes: [], + previousDids: [], + previousTheirDids: [], }) }) }) @@ -202,6 +206,8 @@ describe('0.1-0.2 | Connection', () => { label: 'test', }, connectionTypes: [], + previousDids: [], + previousTheirDids: [], }) }) @@ -275,6 +281,8 @@ describe('0.1-0.2 | Connection', () => { metadata: {}, _tags: {}, connectionTypes: [], + previousDids: [], + previousTheirDids: [], }) }) @@ -346,6 +354,8 @@ describe('0.1-0.2 | Connection', () => { label: 'test', }, connectionTypes: [], + previousDids: [], + previousTheirDids: [], }) }) }) @@ -373,6 +383,8 @@ describe('0.1-0.2 | Connection', () => { autoAcceptConnection: true, mediatorId: 'a-mediator-id', connectionTypes: [], + previousDids: [], + previousTheirDids: [], }) }) @@ -499,6 +511,8 @@ describe('0.1-0.2 | Connection', () => { mediatorId: 'a-mediator-id', outOfBandId: outOfBandRecord.id, connectionTypes: [], + previousDids: [], + previousTheirDids: [], }) }) diff --git a/packages/core/src/storage/migration/updates/0.2-0.3/__tests__/connection.test.ts b/packages/core/src/storage/migration/updates/0.2-0.3/__tests__/connection.test.ts index 0e397e3419..64ada8c5d2 100644 --- a/packages/core/src/storage/migration/updates/0.2-0.3/__tests__/connection.test.ts +++ b/packages/core/src/storage/migration/updates/0.2-0.3/__tests__/connection.test.ts @@ -104,6 +104,8 @@ describe('0.2-0.3 | Connection', () => { _tags: { connectionType: undefined, }, + previousDids: [], + previousTheirDids: [], metadata: {}, }) }) @@ -128,6 +130,8 @@ describe('0.2-0.3 | Connection', () => { _tags: { connectionType: undefined, }, + previousDids: [], + previousTheirDids: [], metadata: {}, }) }) @@ -146,6 +150,8 @@ describe('0.2-0.3 | Connection', () => { expect(connectionRecord.toJSON()).toEqual({ ...connectionRecordProps, connectionTypes: [], + previousDids: [], + previousTheirDids: [], _tags: { connectionType: undefined, }, @@ -174,6 +180,8 @@ describe('0.2-0.3 | Connection', () => { connectionType: undefined, }, metadata: {}, + previousDids: [], + previousTheirDids: [], }) }) }) diff --git a/packages/core/tests/helpers.ts b/packages/core/tests/helpers.ts index 4ad2392ceb..b1312050a8 100644 --- a/packages/core/tests/helpers.ts +++ b/packages/core/tests/helpers.ts @@ -15,6 +15,8 @@ import type { ConnectionStateChangedEvent, Buffer, RevocationNotificationReceivedEvent, + AgentMessageProcessedEvent, + AgentMessageReceivedEvent, } from '../src' import type { AgentModulesInput, EmptyModuleMap } from '../src/agent/AgentModules' import type { TrustPingReceivedEvent, TrustPingResponseReceivedEvent } from '../src/modules/connections/TrustPingEvents' @@ -45,6 +47,7 @@ import { InjectionSymbols, ProofEventTypes, TrustPingEventTypes, + AgentEventTypes, } from '../src' import { Key, KeyType } from '../src/crypto' import { DidKey } from '../src/modules/dids/methods/key' @@ -218,6 +221,8 @@ const isTrustPingReceivedEvent = (e: BaseEvent): e is TrustPingReceivedEvent => e.type === TrustPingEventTypes.TrustPingReceivedEvent 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, @@ -344,6 +349,50 @@ export function waitForTrustPingResponseReceivedEventSubject( ) } +export async function waitForAgentMessageProcessedEvent( + agent: Agent, + options: { + threadId?: string + messageType?: string + timeoutMs?: number + } +) { + const observable = agent.events.observable(AgentEventTypes.AgentMessageProcessed) + + return waitForAgentMessageProcessedEventSubject(observable, options) +} + +export function waitForAgentMessageProcessedEventSubject( + subject: ReplaySubject | Observable, + { + threadId, + timeoutMs = 10000, + messageType, + }: { + threadId?: string + messageType?: string + timeoutMs?: number + } +) { + const observable = subject instanceof ReplaySubject ? subject.asObservable() : subject + return firstValueFrom( + observable.pipe( + filter(isAgentMessageProcessedEvent), + filter((e) => threadId === undefined || e.payload.message.threadId === threadId), + filter((e) => messageType === undefined || e.payload.message.type === messageType), + timeout(timeoutMs), + catchError(() => { + throw new Error( + `AgentMessageProcessedEvent event not emitted within specified timeout: ${timeoutMs} + threadId: ${threadId}, messageType: ${messageType} +}` + ) + }), + map((e) => e.payload.message) + ) + ) +} + export function waitForCredentialRecordSubject( subject: ReplaySubject | Observable, { @@ -440,12 +489,17 @@ export async function waitForConnectionRecord( return waitForConnectionRecordSubject(observable, options) } -export async function waitForBasicMessage(agent: Agent, { content }: { content?: string }): Promise { +export async function waitForBasicMessage( + agent: Agent, + { content, connectionId }: { content?: string; connectionId?: string } +): Promise { return new Promise((resolve) => { const listener = (event: BasicMessageStateChangedEvent) => { const contentMatches = content === undefined || event.payload.message.content === content + const connectionIdMatches = + connectionId === undefined || event.payload.basicMessageRecord.connectionId === connectionId - if (contentMatches) { + if (contentMatches && connectionIdMatches) { agent.events.off(BasicMessageEventTypes.BasicMessageStateChanged, listener) resolve(event.payload.message) diff --git a/tests/transport/SubjectInboundTransport.ts b/tests/transport/SubjectInboundTransport.ts index 6e3b5468a2..7cf6932dec 100644 --- a/tests/transport/SubjectInboundTransport.ts +++ b/tests/transport/SubjectInboundTransport.ts @@ -48,7 +48,16 @@ export class SubjectInboundTransport implements InboundTransport { }) } - await messageReceiver.receiveMessage(message, { session }) + // This emits a custom error in order to easily catch in E2E tests when a message + // reception throws an error. TODO: Remove as soon as agent throws errors automatically + try { + await messageReceiver.receiveMessage(message, { session }) + } catch (error) { + agent.events.emit(agent.context, { + type: 'AgentReceiveMessageError', + payload: error, + }) + } }, }) }