diff --git a/packages/core/src/modules/connections/DidExchangeProtocol.ts b/packages/core/src/modules/connections/DidExchangeProtocol.ts index 5f83ab1a73..531b133e56 100644 --- a/packages/core/src/modules/connections/DidExchangeProtocol.ts +++ b/packages/core/src/modules/connections/DidExchangeProtocol.ts @@ -26,6 +26,7 @@ import { PeerDidNumAlgo, } from '../dids' import { getKeyFromVerificationMethod } from '../dids/domain/key-type' +import { tryParseDid } from '../dids/domain/parse' import { didKeyToInstanceOfKey } from '../dids/helpers' import { DidRecord, DidRepository } from '../dids/repository' import { OutOfBandRole } from '../oob/domain/OutOfBandRole' @@ -104,7 +105,7 @@ export class DidExchangeProtocol { // Create message const label = params.label ?? agentContext.config.label const didDocument = await this.createPeerDidDoc(agentContext, this.routingToServices(routing)) - const parentThreadId = outOfBandInvitation.id + const parentThreadId = outOfBandRecord.outOfBandInvitation.id const message = new DidExchangeRequestMessage({ label, parentThreadId, did: didDocument.id, goal, goalCode }) @@ -146,9 +147,13 @@ export class DidExchangeProtocol { const { message } = messageContext - // Check corresponding invitation ID is the request's ~thread.pthid + // Check corresponding invitation ID is the request's ~thread.pthid or pthid is a public did // TODO Maybe we can do it in handler, but that actually does not make sense because we try to find oob by parent thread ID there. - if (!message.thread?.parentThreadId || message.thread?.parentThreadId !== outOfBandRecord.getTags().invitationId) { + const parentThreadId = message.thread?.parentThreadId + if ( + !parentThreadId || + (!tryParseDid(parentThreadId) && parentThreadId !== outOfBandRecord.getTags().invitationId) + ) { throw new DidExchangeProblemReportError('Missing reference to invitation.', { problemCode: DidExchangeProblemReportReason.RequestNotAccepted, }) @@ -401,8 +406,8 @@ export class DidExchangeProtocol { problemCode: DidExchangeProblemReportReason.CompleteRejected, }) } - - if (!message.thread?.parentThreadId || message.thread?.parentThreadId !== outOfBandRecord.getTags().invitationId) { + const pthid = message.thread?.parentThreadId + if (!pthid || pthid !== outOfBandRecord.outOfBandInvitation.id) { throw new DidExchangeProblemReportError('Invalid or missing parent thread ID referencing to the invitation.', { problemCode: DidExchangeProblemReportReason.CompleteRejected, }) diff --git a/packages/core/src/modules/connections/handlers/ConnectionRequestHandler.ts b/packages/core/src/modules/connections/handlers/ConnectionRequestHandler.ts index fdb6799028..0cbead3793 100644 --- a/packages/core/src/modules/connections/handlers/ConnectionRequestHandler.ts +++ b/packages/core/src/modules/connections/handlers/ConnectionRequestHandler.ts @@ -7,7 +7,9 @@ import type { ConnectionService } from '../services/ConnectionService' import { OutboundMessageContext } from '../../../agent/models' import { AriesFrameworkError } from '../../../error/AriesFrameworkError' +import { tryParseDid } from '../../dids/domain/parse' import { ConnectionRequestMessage } from '../messages' +import { HandshakeProtocol } from '../models' export class ConnectionRequestHandler implements MessageHandler { private connectionService: ConnectionService @@ -32,16 +34,23 @@ export class ConnectionRequestHandler implements MessageHandler { } public async handle(messageContext: MessageHandlerInboundMessage) { - const { connection, recipientKey, senderKey } = messageContext + const { agentContext, connection, recipientKey, senderKey, message } = messageContext if (!recipientKey || !senderKey) { throw new AriesFrameworkError('Unable to process connection request without senderVerkey or recipientKey') } - const outOfBandRecord = await this.outOfBandService.findCreatedByRecipientKey( - messageContext.agentContext, - recipientKey - ) + const parentThreadId = message.thread?.parentThreadId + + const outOfBandRecord = + parentThreadId && tryParseDid(parentThreadId) + ? await this.outOfBandService.createFromImplicitInvitation(agentContext, { + did: parentThreadId, + threadId: message.threadId, + recipientKey, + handshakeProtocols: [HandshakeProtocol.Connections], + }) + : await this.outOfBandService.findCreatedByRecipientKey(agentContext, recipientKey) if (!outOfBandRecord) { throw new AriesFrameworkError(`Out-of-band record for recipient key ${recipientKey.fingerprint} was not found.`) diff --git a/packages/core/src/modules/connections/handlers/DidExchangeCompleteHandler.ts b/packages/core/src/modules/connections/handlers/DidExchangeCompleteHandler.ts index 76f885e82b..5d4ad8eb6a 100644 --- a/packages/core/src/modules/connections/handlers/DidExchangeCompleteHandler.ts +++ b/packages/core/src/modules/connections/handlers/DidExchangeCompleteHandler.ts @@ -3,6 +3,7 @@ import type { OutOfBandService } from '../../oob/OutOfBandService' import type { DidExchangeProtocol } from '../DidExchangeProtocol' import { AriesFrameworkError } from '../../../error' +import { tryParseDid } from '../../dids/domain/parse' import { OutOfBandState } from '../../oob/domain/OutOfBandState' import { DidExchangeCompleteMessage } from '../messages' import { HandshakeProtocol } from '../models' @@ -32,12 +33,14 @@ export class DidExchangeCompleteHandler implements MessageHandler { } const { message } = messageContext - if (!message.thread?.parentThreadId) { + const parentThreadId = message.thread?.parentThreadId + if (!parentThreadId) { throw new AriesFrameworkError(`Message does not contain pthid attribute`) } const outOfBandRecord = await this.outOfBandService.findByCreatedInvitationId( messageContext.agentContext, - message.thread?.parentThreadId + parentThreadId, + tryParseDid(parentThreadId) ? message.threadId : undefined ) if (!outOfBandRecord) { diff --git a/packages/core/src/modules/connections/handlers/DidExchangeRequestHandler.ts b/packages/core/src/modules/connections/handlers/DidExchangeRequestHandler.ts index 2e3bcb740d..3983fd0a89 100644 --- a/packages/core/src/modules/connections/handlers/DidExchangeRequestHandler.ts +++ b/packages/core/src/modules/connections/handlers/DidExchangeRequestHandler.ts @@ -7,8 +7,10 @@ import type { DidExchangeProtocol } from '../DidExchangeProtocol' import { OutboundMessageContext } from '../../../agent/models' import { AriesFrameworkError } from '../../../error/AriesFrameworkError' +import { tryParseDid } from '../../dids/domain/parse' import { OutOfBandState } from '../../oob/domain/OutOfBandState' import { DidExchangeRequestMessage } from '../messages' +import { HandshakeProtocol } from '../models' export class DidExchangeRequestHandler implements MessageHandler { private didExchangeProtocol: DidExchangeProtocol @@ -33,22 +35,28 @@ export class DidExchangeRequestHandler implements MessageHandler { } public async handle(messageContext: MessageHandlerInboundMessage) { - const { recipientKey, senderKey, message, connection } = messageContext + const { agentContext, recipientKey, senderKey, message, connection } = messageContext if (!recipientKey || !senderKey) { throw new AriesFrameworkError('Unable to process connection request without senderKey or recipientKey') } - if (!message.thread?.parentThreadId) { + const parentThreadId = message.thread?.parentThreadId + + if (!parentThreadId) { throw new AriesFrameworkError(`Message does not contain 'pthid' attribute`) } - const outOfBandRecord = await this.outOfBandService.findByCreatedInvitationId( - messageContext.agentContext, - message.thread.parentThreadId - ) + const outOfBandRecord = tryParseDid(parentThreadId) + ? await this.outOfBandService.createFromImplicitInvitation(agentContext, { + did: parentThreadId, + threadId: message.threadId, + recipientKey, + handshakeProtocols: [HandshakeProtocol.DidExchange], + }) + : await this.outOfBandService.findByCreatedInvitationId(agentContext, parentThreadId) if (!outOfBandRecord) { - throw new AriesFrameworkError(`OutOfBand record for message ID ${message.thread?.parentThreadId} not found!`) + throw new AriesFrameworkError(`OutOfBand record for message ID ${parentThreadId} not found!`) } if (connection && !outOfBandRecord.reusable) { diff --git a/packages/core/src/modules/connections/services/ConnectionService.ts b/packages/core/src/modules/connections/services/ConnectionService.ts index 539fabac8c..ea7adab79e 100644 --- a/packages/core/src/modules/connections/services/ConnectionService.ts +++ b/packages/core/src/modules/connections/services/ConnectionService.ts @@ -121,7 +121,7 @@ export class ConnectionService { connectionRequest.setThread({ threadId: connectionRequest.threadId, - parentThreadId: outOfBandInvitation.id, + parentThreadId: outOfBandRecord.outOfBandInvitation.id, }) const connectionRecord = await this.createConnection(agentContext, { diff --git a/packages/core/src/modules/oob/OutOfBandApi.ts b/packages/core/src/modules/oob/OutOfBandApi.ts index 5f1e0c00b7..d96bfe2f16 100644 --- a/packages/core/src/modules/oob/OutOfBandApi.ts +++ b/packages/core/src/modules/oob/OutOfBandApi.ts @@ -67,7 +67,7 @@ export interface CreateLegacyInvitationConfig { routing?: Routing } -export interface ReceiveOutOfBandInvitationConfig { +interface BaseReceiveOutOfBandInvitationConfig { label?: string alias?: string imageUrl?: string @@ -76,6 +76,15 @@ export interface ReceiveOutOfBandInvitationConfig { reuseConnection?: boolean routing?: Routing acceptInvitationTimeoutMs?: number + isImplicit?: boolean +} + +export type ReceiveOutOfBandInvitationConfig = Omit + +export interface ReceiveOutOfBandImplicitInvitationConfig + extends Omit { + did: string + handshakeProtocols?: HandshakeProtocol[] } @injectable() @@ -321,6 +330,44 @@ export class OutOfBandApi { public async receiveInvitation( invitation: OutOfBandInvitation | ConnectionInvitationMessage, config: ReceiveOutOfBandInvitationConfig = {} + ): Promise<{ outOfBandRecord: OutOfBandRecord; connectionRecord?: ConnectionRecord }> { + return this._receiveInvitation(invitation, config) + } + + /** + * Creates inbound out-of-band record from an implicit invitation, given as a public DID the agent + * should be capable of resolving. It automatically passes out-of-band invitation for further + * processing to `acceptInvitation` method. If you don't want to do that you can set + * `autoAcceptInvitation` attribute in `config` parameter to `false` and accept the message later by + * calling `acceptInvitation`. + * + * It supports both OOB (Aries RFC 0434: Out-of-Band Protocol 1.1) and Connection Invitation + * (0160: Connection Protocol). Handshake protocol to be used depends on handshakeProtocols + * (DID Exchange by default) + * + * Agent role: receiver (invitee) + * + * @param config config for creating and handling invitation + * + * @returns out-of-band record and connection record if one has been created. + */ + public async receiveImplicitInvitation(config: ReceiveOutOfBandImplicitInvitationConfig) { + const invitation = new OutOfBandInvitation({ + id: config.did, + label: config.label ?? '', + services: [config.did], + handshakeProtocols: config.handshakeProtocols ?? [HandshakeProtocol.DidExchange], + }) + + return this._receiveInvitation(invitation, { ...config, isImplicit: true }) + } + + /** + * Internal receive invitation method, for both explicit and implicit OOB invitations + */ + private async _receiveInvitation( + invitation: OutOfBandInvitation | ConnectionInvitationMessage, + config: BaseReceiveOutOfBandInvitationConfig = {} ): Promise<{ outOfBandRecord: OutOfBandRecord; connectionRecord?: ConnectionRecord }> { // Convert to out of band invitation if needed const outOfBandInvitation = @@ -344,15 +391,19 @@ export class OutOfBandApi { ) } - // Make sure we haven't received this invitation before. (it's fine if we created it, that means we're connecting with ourselves - let [outOfBandRecord] = await this.outOfBandService.findAllByQuery(this.agentContext, { - invitationId: outOfBandInvitation.id, - role: OutOfBandRole.Receiver, - }) - if (outOfBandRecord) { - throw new AriesFrameworkError( - `An out of band record with invitation ${outOfBandInvitation.id} has already been received. Invitations should have a unique id.` - ) + // Make sure we haven't received this invitation before + // It's fine if we created it (means that we are connnecting to ourselves) or if it's an implicit + // invitation (it allows to connect multiple times to the same public did) + if (!config.isImplicit) { + const existingOobRecordsFromThisId = await this.outOfBandService.findAllByQuery(this.agentContext, { + invitationId: outOfBandInvitation.id, + role: OutOfBandRole.Receiver, + }) + if (existingOobRecordsFromThisId.length > 0) { + throw new AriesFrameworkError( + `An out of band record with invitation ${outOfBandInvitation.id} has already been received. Invitations should have a unique id.` + ) + } } const recipientKeyFingerprints: string[] = [] @@ -374,7 +425,7 @@ export class OutOfBandApi { } } - outOfBandRecord = new OutOfBandRecord({ + const outOfBandRecord = new OutOfBandRecord({ role: OutOfBandRole.Receiver, state: OutOfBandState.Initial, outOfBandInvitation: outOfBandInvitation, @@ -430,11 +481,12 @@ export class OutOfBandApi { const { outOfBandInvitation } = outOfBandRecord const { label, alias, imageUrl, autoAcceptConnection, reuseConnection, routing } = config - const { handshakeProtocols } = outOfBandInvitation const services = outOfBandInvitation.getServices() const messages = outOfBandInvitation.getRequests() const timeoutMs = config.timeoutMs ?? 20000 + const { handshakeProtocols } = outOfBandInvitation + const existingConnection = await this.findExistingConnection(outOfBandInvitation) await this.outOfBandService.updateState(this.agentContext, outOfBandRecord, OutOfBandState.PrepareResponse) diff --git a/packages/core/src/modules/oob/OutOfBandService.ts b/packages/core/src/modules/oob/OutOfBandService.ts index 377e8867c2..1802b03064 100644 --- a/packages/core/src/modules/oob/OutOfBandService.ts +++ b/packages/core/src/modules/oob/OutOfBandService.ts @@ -1,22 +1,32 @@ import type { HandshakeReusedEvent, OutOfBandStateChangedEvent } from './domain/OutOfBandEvents' -import type { OutOfBandRecord } from './repository' import type { AgentContext } from '../../agent' import type { InboundMessageContext } from '../../agent/models/InboundMessageContext' import type { Key } from '../../crypto' import type { Query } from '../../storage/StorageService' import type { ConnectionRecord } from '../connections' +import type { HandshakeProtocol } from '../connections/models' import { EventEmitter } from '../../agent/EventEmitter' import { AriesFrameworkError } from '../../error' import { injectable } from '../../plugins' import { JsonTransformer } from '../../utils' +import { DidsApi } from '../dids' +import { parseDid } from '../dids/domain/parse' import { OutOfBandEventTypes } from './domain/OutOfBandEvents' import { OutOfBandRole } from './domain/OutOfBandRole' import { OutOfBandState } from './domain/OutOfBandState' -import { HandshakeReuseMessage } from './messages' +import { HandshakeReuseMessage, OutOfBandInvitation } from './messages' import { HandshakeReuseAcceptedMessage } from './messages/HandshakeReuseAcceptedMessage' -import { OutOfBandRepository } from './repository' +import { OutOfBandRecord, OutOfBandRepository } from './repository' + +export interface CreateFromImplicitInvitationConfig { + did: string + threadId: string + handshakeProtocols: HandshakeProtocol[] + autoAcceptConnection?: boolean + recipientKey: Key +} @injectable() export class OutOfBandService { @@ -28,6 +38,51 @@ export class OutOfBandService { this.eventEmitter = eventEmitter } + /** + * Creates an Out of Band record from a Connection/DIDExchange request started by using + * a publicly resolvable DID this agent can control + */ + public async createFromImplicitInvitation( + agentContext: AgentContext, + config: CreateFromImplicitInvitationConfig + ): Promise { + const { did, threadId, handshakeProtocols, autoAcceptConnection, recipientKey } = config + + // Verify it is a valid did and it is present in the wallet + const publicDid = parseDid(did) + const didsApi = agentContext.dependencyManager.resolve(DidsApi) + const [createdDid] = await didsApi.getCreatedDids({ did: publicDid.did }) + if (!createdDid) { + throw new AriesFrameworkError(`Referenced public did ${did} not found.`) + } + + // Recreate an 'implicit invitation' matching the parameters used by the invitee when + // initiating the flow + const outOfBandInvitation = new OutOfBandInvitation({ + id: did, + label: '', + services: [did], + handshakeProtocols, + }) + + outOfBandInvitation.setThread({ threadId }) + + const outOfBandRecord = new OutOfBandRecord({ + role: OutOfBandRole.Sender, + state: OutOfBandState.AwaitResponse, + reusable: true, + autoAcceptConnection: autoAcceptConnection ?? false, + outOfBandInvitation, + tags: { + recipientKeyFingerprints: [recipientKey.fingerprint], + }, + }) + + await this.save(agentContext, outOfBandRecord) + this.emitStateChangedEvent(agentContext, outOfBandRecord, null) + return outOfBandRecord + } + public async processHandshakeReuse(messageContext: InboundMessageContext) { const reuseMessage = messageContext.message const parentThreadId = reuseMessage.thread?.parentThreadId @@ -172,10 +227,11 @@ export class OutOfBandService { }) } - public async findByCreatedInvitationId(agentContext: AgentContext, createdInvitationId: string) { + public async findByCreatedInvitationId(agentContext: AgentContext, createdInvitationId: string, threadId?: string) { return this.outOfBandRepository.findSingleByQuery(agentContext, { invitationId: createdInvitationId, role: OutOfBandRole.Sender, + threadId, }) } diff --git a/packages/core/src/modules/oob/__tests__/implicit.e2e.test.ts b/packages/core/src/modules/oob/__tests__/implicit.e2e.test.ts new file mode 100644 index 0000000000..ae0c98e6d7 --- /dev/null +++ b/packages/core/src/modules/oob/__tests__/implicit.e2e.test.ts @@ -0,0 +1,216 @@ +/* eslint-disable @typescript-eslint/no-non-null-assertion */ +import type { IndySdkSovDidCreateOptions } from '@aries-framework/indy-sdk' + +import { getLegacyAnonCredsModules } from '../../../../../anoncreds/tests/legacyAnonCredsSetup' +import { setupSubjectTransports } from '../../../../tests' +import { + getAgentOptions, + importExistingIndyDidFromPrivateKey, + publicDidSeed, + waitForConnectionRecord, +} from '../../../../tests/helpers' +import { Agent } from '../../../agent/Agent' +import { TypedArrayEncoder } from '../../../utils' +import { sleep } from '../../../utils/sleep' +import { DidExchangeState, HandshakeProtocol } from '../../connections' + +const faberAgentOptions = getAgentOptions( + 'Faber Agent OOB Implicit', + { + endpoints: ['rxjs:faber'], + }, + getLegacyAnonCredsModules() +) +const aliceAgentOptions = getAgentOptions( + 'Alice Agent OOB Implicit', + { + endpoints: ['rxjs:alice'], + }, + getLegacyAnonCredsModules() +) + +describe('out of band implicit', () => { + let faberAgent: Agent + let aliceAgent: Agent + let unqualifiedSubmitterDid: string + + beforeAll(async () => { + faberAgent = new Agent(faberAgentOptions) + aliceAgent = new Agent(aliceAgentOptions) + + setupSubjectTransports([faberAgent, aliceAgent]) + await faberAgent.initialize() + await aliceAgent.initialize() + + unqualifiedSubmitterDid = await importExistingIndyDidFromPrivateKey( + faberAgent, + TypedArrayEncoder.fromString(publicDidSeed) + ) + }) + + afterAll(async () => { + await faberAgent.shutdown() + await faberAgent.wallet.delete() + await aliceAgent.shutdown() + await aliceAgent.wallet.delete() + }) + + afterEach(async () => { + const connections = await faberAgent.connections.getAll() + for (const connection of connections) { + await faberAgent.connections.deleteById(connection.id) + } + + jest.resetAllMocks() + }) + + test(`make a connection with ${HandshakeProtocol.DidExchange} based on implicit OOB invitation`, async () => { + const publicDid = await createPublicDid(faberAgent, unqualifiedSubmitterDid, 'rxjs:faber') + expect(publicDid).toBeDefined() + + let { connectionRecord: aliceFaberConnection } = await aliceAgent.oob.receiveImplicitInvitation({ + did: publicDid!, + alias: 'Faber public', + label: 'Alice', + handshakeProtocols: [HandshakeProtocol.DidExchange], + }) + + // Wait for a connection event in faber agent and accept the request + let faberAliceConnection = await waitForConnectionRecord(faberAgent, { state: DidExchangeState.RequestReceived }) + await faberAgent.connections.acceptRequest(faberAliceConnection.id) + faberAliceConnection = await faberAgent.connections.returnWhenIsConnected(faberAliceConnection!.id) + expect(faberAliceConnection.state).toBe(DidExchangeState.Completed) + + // Alice should now be connected + aliceFaberConnection = await aliceAgent.connections.returnWhenIsConnected(aliceFaberConnection!.id) + expect(aliceFaberConnection.state).toBe(DidExchangeState.Completed) + + expect(aliceFaberConnection).toBeConnectedWith(faberAliceConnection) + expect(faberAliceConnection).toBeConnectedWith(aliceFaberConnection) + expect(faberAliceConnection.theirLabel).toBe('Alice') + expect(aliceFaberConnection.alias).toBe('Faber public') + expect(aliceFaberConnection.invitationDid).toBe(publicDid) + + // It is possible for an agent to check if it has already a connection to a certain public entity + expect(await aliceAgent.connections.findByInvitationDid(publicDid!)).toEqual([aliceFaberConnection]) + }) + + test(`make a connection with ${HandshakeProtocol.Connections} based on implicit OOB invitation`, async () => { + const publicDid = await createPublicDid(faberAgent, unqualifiedSubmitterDid, 'rxjs:faber') + expect(publicDid).toBeDefined() + + let { connectionRecord: aliceFaberConnection } = await aliceAgent.oob.receiveImplicitInvitation({ + did: publicDid!, + alias: 'Faber public', + label: 'Alice', + handshakeProtocols: [HandshakeProtocol.Connections], + }) + + // Wait for a connection event in faber agent and accept the request + let faberAliceConnection = await waitForConnectionRecord(faberAgent, { state: DidExchangeState.RequestReceived }) + await faberAgent.connections.acceptRequest(faberAliceConnection.id) + faberAliceConnection = await faberAgent.connections.returnWhenIsConnected(faberAliceConnection!.id) + expect(faberAliceConnection.state).toBe(DidExchangeState.Completed) + + // Alice should now be connected + aliceFaberConnection = await aliceAgent.connections.returnWhenIsConnected(aliceFaberConnection!.id) + expect(aliceFaberConnection.state).toBe(DidExchangeState.Completed) + + expect(aliceFaberConnection).toBeConnectedWith(faberAliceConnection) + expect(faberAliceConnection).toBeConnectedWith(aliceFaberConnection) + expect(faberAliceConnection.theirLabel).toBe('Alice') + expect(aliceFaberConnection.alias).toBe('Faber public') + expect(aliceFaberConnection.invitationDid).toBe(publicDid) + + // It is possible for an agent to check if it has already a connection to a certain public entity + expect(await aliceAgent.connections.findByInvitationDid(publicDid!)).toEqual([aliceFaberConnection]) + }) + + test(`receive an implicit invitation using an unresolvable did`, async () => { + await expect( + aliceAgent.oob.receiveImplicitInvitation({ + did: 'did:sov:ZSEqSci581BDZCFPa29ScB', + alias: 'Faber public', + label: 'Alice', + handshakeProtocols: [HandshakeProtocol.DidExchange], + }) + ).rejects.toThrowError(/Unable to resolve did/) + }) + + test(`create two connections using the same implicit invitation`, async () => { + const publicDid = await createPublicDid(faberAgent, unqualifiedSubmitterDid, 'rxjs:faber') + expect(publicDid).toBeDefined() + + let { connectionRecord: aliceFaberConnection } = await aliceAgent.oob.receiveImplicitInvitation({ + did: publicDid!, + alias: 'Faber public', + label: 'Alice', + handshakeProtocols: [HandshakeProtocol.Connections], + }) + + // Wait for a connection event in faber agent and accept the request + let faberAliceConnection = await waitForConnectionRecord(faberAgent, { state: DidExchangeState.RequestReceived }) + await faberAgent.connections.acceptRequest(faberAliceConnection.id) + faberAliceConnection = await faberAgent.connections.returnWhenIsConnected(faberAliceConnection!.id) + expect(faberAliceConnection.state).toBe(DidExchangeState.Completed) + + // Alice should now be connected + aliceFaberConnection = await aliceAgent.connections.returnWhenIsConnected(aliceFaberConnection!.id) + expect(aliceFaberConnection.state).toBe(DidExchangeState.Completed) + + expect(aliceFaberConnection).toBeConnectedWith(faberAliceConnection) + expect(faberAliceConnection).toBeConnectedWith(aliceFaberConnection) + expect(faberAliceConnection.theirLabel).toBe('Alice') + expect(aliceFaberConnection.alias).toBe('Faber public') + expect(aliceFaberConnection.invitationDid).toBe(publicDid) + + // Repeat implicit invitation procedure + let { connectionRecord: aliceFaberNewConnection } = await aliceAgent.oob.receiveImplicitInvitation({ + did: publicDid!, + alias: 'Faber public New', + label: 'Alice New', + handshakeProtocols: [HandshakeProtocol.Connections], + }) + + // Wait for a connection event in faber agent + let faberAliceNewConnection = await waitForConnectionRecord(faberAgent, { state: DidExchangeState.RequestReceived }) + await faberAgent.connections.acceptRequest(faberAliceNewConnection.id) + faberAliceNewConnection = await faberAgent.connections.returnWhenIsConnected(faberAliceNewConnection!.id) + expect(faberAliceNewConnection.state).toBe(DidExchangeState.Completed) + + // Alice should now be connected + aliceFaberNewConnection = await aliceAgent.connections.returnWhenIsConnected(aliceFaberNewConnection!.id) + expect(aliceFaberNewConnection.state).toBe(DidExchangeState.Completed) + + expect(aliceFaberNewConnection).toBeConnectedWith(faberAliceNewConnection) + expect(faberAliceNewConnection).toBeConnectedWith(aliceFaberNewConnection) + expect(faberAliceNewConnection.theirLabel).toBe('Alice New') + expect(aliceFaberNewConnection.alias).toBe('Faber public New') + expect(aliceFaberNewConnection.invitationDid).toBe(publicDid) + + // Both connections will be associated to the same invitation did + const connectionsFromFaberPublicDid = await aliceAgent.connections.findByInvitationDid(publicDid!) + expect(connectionsFromFaberPublicDid).toHaveLength(2) + expect(connectionsFromFaberPublicDid).toEqual( + expect.arrayContaining([aliceFaberConnection, aliceFaberNewConnection]) + ) + }) +}) + +async function createPublicDid(agent: Agent, unqualifiedSubmitterDid: string, endpoint: string) { + const createResult = await agent.dids.create({ + method: 'sov', + options: { + submitterVerificationMethod: `did:sov:${unqualifiedSubmitterDid}#key-1`, + alias: 'Alias', + endpoints: { + endpoint, + types: ['DIDComm', 'did-communication', 'endpoint'], + }, + }, + }) + + await sleep(1000) + + return createResult.didState.did +} diff --git a/packages/core/src/modules/oob/repository/OutOfBandRecord.ts b/packages/core/src/modules/oob/repository/OutOfBandRecord.ts index ec291225c2..202e6a3886 100644 --- a/packages/core/src/modules/oob/repository/OutOfBandRecord.ts +++ b/packages/core/src/modules/oob/repository/OutOfBandRecord.ts @@ -13,6 +13,7 @@ type DefaultOutOfBandRecordTags = { role: OutOfBandRole state: OutOfBandState invitationId: string + threadId?: string } interface CustomOutOfBandRecordTags extends TagsBase { @@ -32,6 +33,7 @@ export interface OutOfBandRecordProps { reusable?: boolean mediatorId?: string reuseConnectionId?: string + threadId?: string } export class OutOfBandRecord extends BaseRecord { @@ -72,6 +74,7 @@ export class OutOfBandRecord extends BaseRecord { state: OutOfBandState.Done, role: OutOfBandRole.Receiver, invitationId: 'a-message-id', + threadId: 'a-message-id', recipientKeyFingerprints: ['z6MkmjY8GnV5i9YTDtPETC2uUAW6ejw3nk5mXF5yci5ab7th'], }) }) 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 a64116a5f5..6df70279be 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 @@ -788,6 +788,7 @@ Object { ], "role": "receiver", "state": "done", + "threadId": "d56fd7af-852e-458e-b750-7a4f4e53d6e6", }, "type": "OutOfBandRecord", "value": Object { @@ -853,6 +854,7 @@ Object { ], "role": "sender", "state": "done", + "threadId": "d939d371-3155-4d9c-87d1-46447f624f44", }, "type": "OutOfBandRecord", "value": Object { @@ -918,6 +920,7 @@ Object { ], "role": "sender", "state": "done", + "threadId": "21ef606f-b25b-48c6-bafa-e79193732413", }, "type": "OutOfBandRecord", "value": Object { @@ -983,6 +986,7 @@ Object { ], "role": "receiver", "state": "done", + "threadId": "08eb8d8b-67cf-4ce2-9aca-c7d260a5c143", }, "type": "OutOfBandRecord", "value": Object { @@ -1048,6 +1052,7 @@ Object { ], "role": "receiver", "state": "done", + "threadId": "cc67fb5e-1414-4ba6-9030-7456ccd2aaea", }, "type": "OutOfBandRecord", "value": Object { @@ -1113,6 +1118,7 @@ Object { ], "role": "sender", "state": "await-response", + "threadId": "f0ca03d8-2e11-4ff2-a5fc-e0137a434b7e", }, "type": "OutOfBandRecord", "value": Object { @@ -1173,6 +1179,7 @@ Object { ], "role": "sender", "state": "await-response", + "threadId": "1f516e35-08d3-43d8-900c-99d5239f54da", }, "type": "OutOfBandRecord", "value": Object { diff --git a/packages/core/tests/helpers.ts b/packages/core/tests/helpers.ts index 3309c1afdf..2d11ae4109 100644 --- a/packages/core/tests/helpers.ts +++ b/packages/core/tests/helpers.ts @@ -12,6 +12,7 @@ import type { Wallet, Agent, CredentialState, + ConnectionStateChangedEvent, Buffer, } from '../src' import type { AgentModulesInput, EmptyModuleMap } from '../src/agent/AgentModules' @@ -27,6 +28,7 @@ import { catchError, filter, map, take, timeout } from 'rxjs/operators' import { agentDependencies, IndySdkPostgresWalletScheme } from '../../node/src' import { + ConnectionEventTypes, TypedArrayEncoder, AgentConfig, AgentContext, @@ -189,6 +191,8 @@ const isProofStateChangedEvent = (e: BaseEvent): e is ProofStateChangedEvent => e.type === ProofEventTypes.ProofStateChanged const isCredentialStateChangedEvent = (e: BaseEvent): e is CredentialStateChangedEvent => e.type === CredentialEventTypes.CredentialStateChanged +const isConnectionStateChangedEvent = (e: BaseEvent): e is ConnectionStateChangedEvent => + e.type === ConnectionEventTypes.ConnectionStateChanged const isTrustPingReceivedEvent = (e: BaseEvent): e is TrustPingReceivedEvent => e.type === TrustPingEventTypes.TrustPingReceivedEvent const isTrustPingResponseReceivedEvent = (e: BaseEvent): e is TrustPingResponseReceivedEvent => @@ -367,6 +371,54 @@ export async function waitForCredentialRecord( return waitForCredentialRecordSubject(observable, options) } +export function waitForConnectionRecordSubject( + subject: ReplaySubject | Observable, + { + threadId, + state, + previousState, + timeoutMs = 15000, // sign and store credential in W3c credential protocols take several seconds + }: { + threadId?: string + state?: DidExchangeState + previousState?: DidExchangeState | null + timeoutMs?: number + } +) { + const observable = subject instanceof ReplaySubject ? subject.asObservable() : subject + + return firstValueFrom( + observable.pipe( + filter(isConnectionStateChangedEvent), + filter((e) => previousState === undefined || e.payload.previousState === previousState), + filter((e) => threadId === undefined || e.payload.connectionRecord.threadId === threadId), + filter((e) => state === undefined || e.payload.connectionRecord.state === state), + timeout(timeoutMs), + catchError(() => { + throw new Error(`ConnectionStateChanged event not emitted within specified timeout: { + previousState: ${previousState}, + threadId: ${threadId}, + state: ${state} +}`) + }), + map((e) => e.payload.connectionRecord) + ) + ) +} + +export async function waitForConnectionRecord( + agent: Agent, + options: { + threadId?: string + state?: DidExchangeState + previousState?: DidExchangeState | null + timeoutMs?: number + } +) { + const observable = agent.events.observable(ConnectionEventTypes.ConnectionStateChanged) + return waitForConnectionRecordSubject(observable, options) +} + export async function waitForBasicMessage(agent: Agent, { content }: { content?: string }): Promise { return new Promise((resolve) => { const listener = (event: BasicMessageStateChangedEvent) => {