diff --git a/README.md b/README.md index 41636ca53a..b7795ee307 100644 --- a/README.md +++ b/README.md @@ -64,12 +64,13 @@ Some features are not yet supported, but are on our roadmap. Check [the roadmap] - ✅ Mediator Coordination Protocol ([RFC 0211](https://github.com/hyperledger/aries-rfcs/blob/master/features/0211-route-coordination/README.md)) - ✅ Indy Credentials (with `did:sov` support) - ✅ HTTP Transport -- ✅ Auto accept proofs +- ✅ Connection-less Issuance and Verification +- ✅ Mediator Coordination Protocol ([RFC 0211](https://github.com/hyperledger/aries-rfcs/blob/master/features/0211-route-coordination/README.md)) +- ✅ Smart Auto Acceptance of Connections, Credentials and Proofs - 🚧 Revocation of Indy Credentials - 🚧 Electron - 🚧 WebSocket Transport - ❌ Browser -- ❌ Connection-less Issuance and Verification - ❌ Issue Credential V2, Present Proof V2, DID Exchange Protocol, Out-Of-Band - ❌ W3C Linked Data VCs, BBS+ Signatures diff --git a/packages/core/src/agent/Agent.ts b/packages/core/src/agent/Agent.ts index d34dd1fcd5..2c0f581d5b 100644 --- a/packages/core/src/agent/Agent.ts +++ b/packages/core/src/agent/Agent.ts @@ -187,6 +187,10 @@ export class Agent { } public async shutdown({ deleteWallet = false }: { deleteWallet?: boolean } = {}) { + // All observables use takeUntil with the stop$ observable + // this means all observables will stop running if a value is emitted on this observable + this.agentConfig.stop$.next(true) + // Stop transports await this.outboundTransporter?.stop() await this.inboundTransporter?.stop() @@ -199,10 +203,6 @@ export class Agent { await this.wallet.close() } } - - // All observables use takeUntil with the stop$ observable - // this means all observables will stop running if a value is emitted on this observable - this.agentConfig.stop$.next(true) } public get publicDid() { diff --git a/packages/core/src/agent/AgentMessage.ts b/packages/core/src/agent/AgentMessage.ts index bc443cab25..b7da4a49eb 100644 --- a/packages/core/src/agent/AgentMessage.ts +++ b/packages/core/src/agent/AgentMessage.ts @@ -1,6 +1,7 @@ import { AckDecorated } from '../decorators/ack/AckDecoratorExtension' import { AttachmentDecorated } from '../decorators/attachment/AttachmentExtension' import { L10nDecorated } from '../decorators/l10n/L10nDecoratorExtension' +import { ServiceDecorated } from '../decorators/service/ServiceDecoratorExtension' import { ThreadDecorated } from '../decorators/thread/ThreadDecoratorExtension' import { TimingDecorated } from '../decorators/timing/TimingDecoratorExtension' import { TransportDecorated } from '../decorators/transport/TransportDecoratorExtension' @@ -16,6 +17,7 @@ const DefaultDecorators = [ TimingDecorated, AckDecorated, AttachmentDecorated, + ServiceDecorated, ] export class AgentMessage extends Compose(BaseMessage, DefaultDecorators) { diff --git a/packages/core/src/agent/Dispatcher.ts b/packages/core/src/agent/Dispatcher.ts index b4122c1322..5d7b55ce35 100644 --- a/packages/core/src/agent/Dispatcher.ts +++ b/packages/core/src/agent/Dispatcher.ts @@ -10,6 +10,7 @@ import { AriesFrameworkError } from '../error/AriesFrameworkError' import { EventEmitter } from './EventEmitter' import { AgentEventTypes } from './Events' import { MessageSender } from './MessageSender' +import { isOutboundServiceMessage } from './helpers' @scoped(Lifecycle.ContainerScoped) class Dispatcher { @@ -36,6 +37,17 @@ class Dispatcher { const outboundMessage = await handler.handle(messageContext) + if (outboundMessage && isOutboundServiceMessage(outboundMessage)) { + await this.messageSender.sendMessageToService({ + message: outboundMessage.payload, + service: outboundMessage.service, + senderKey: outboundMessage.senderKey, + returnRoute: true, + }) + } else if (outboundMessage) { + await this.messageSender.sendMessage(outboundMessage) + } + // Emit event that allows to hook into received messages this.eventEmitter.emit({ type: AgentEventTypes.AgentMessageProcessed, @@ -44,10 +56,6 @@ class Dispatcher { connection: messageContext.connection, }, }) - - if (outboundMessage) { - await this.messageSender.sendMessage(outboundMessage) - } } private getHandlerForType(messageType: string): Handler | undefined { diff --git a/packages/core/src/agent/EnvelopeService.ts b/packages/core/src/agent/EnvelopeService.ts index 07bc24377a..ffbc1e9153 100644 --- a/packages/core/src/agent/EnvelopeService.ts +++ b/packages/core/src/agent/EnvelopeService.ts @@ -1,5 +1,5 @@ import type { Logger } from '../logger' -import type { PackedMessage, UnpackedMessageContext } from '../types' +import type { UnpackedMessageContext, WireMessage } from '../types' import type { AgentMessage } from './AgentMessage' import type { Verkey } from 'indy-sdk' @@ -27,7 +27,7 @@ class EnvelopeService { this.logger = agentConfig.logger } - public async packMessage(payload: AgentMessage, keys: EnvelopeKeys): Promise { + public async packMessage(payload: AgentMessage, keys: EnvelopeKeys): Promise { const { routingKeys, recipientKeys, senderKey: senderVk } = keys const message = payload.toJSON() @@ -50,7 +50,7 @@ class EnvelopeService { return wireMessage } - public async unpackMessage(packedMessage: PackedMessage): Promise { + public async unpackMessage(packedMessage: WireMessage): Promise { return this.wallet.unpack(packedMessage) } } diff --git a/packages/core/src/agent/Handler.ts b/packages/core/src/agent/Handler.ts index 6c37eddd1f..b0db8965b6 100644 --- a/packages/core/src/agent/Handler.ts +++ b/packages/core/src/agent/Handler.ts @@ -1,11 +1,11 @@ -import type { OutboundMessage } from '../types' +import type { OutboundMessage, OutboundServiceMessage } from '../types' import type { AgentMessage } from './AgentMessage' import type { InboundMessageContext } from './models/InboundMessageContext' export interface Handler { readonly supportedMessages: readonly T[] - handle(messageContext: InboundMessageContext): Promise + handle(messageContext: InboundMessageContext): Promise } /** diff --git a/packages/core/src/agent/MessageReceiver.ts b/packages/core/src/agent/MessageReceiver.ts index 2c76e1e850..7a647d0466 100644 --- a/packages/core/src/agent/MessageReceiver.ts +++ b/packages/core/src/agent/MessageReceiver.ts @@ -1,5 +1,5 @@ import type { Logger } from '../logger' -import type { UnpackedMessageContext, UnpackedMessage } from '../types' +import type { UnpackedMessageContext, UnpackedMessage, WireMessage } from '../types' import type { AgentMessage } from './AgentMessage' import type { TransportSession } from './TransportService' @@ -53,13 +53,15 @@ export class MessageReceiver { this.logger.debug(`Agent ${this.config.label} received message`) - const unpackedMessage = await this.unpackMessage(inboundPackedMessage as Record) - const senderKey = unpackedMessage.sender_verkey + const unpackedMessage = await this.unpackMessage(inboundPackedMessage as WireMessage) + const senderKey = unpackedMessage.senderVerkey + const recipientKey = unpackedMessage.recipientVerkey + let connection = undefined - if (senderKey && unpackedMessage.recipient_verkey) { + if (senderKey && recipientKey) { // TODO: only attach if theirKey is present. Otherwise a connection that may not be complete, validated or correct will // be attached to the message context. See #76 - connection = (await this.connectionService.findByVerkey(unpackedMessage.recipient_verkey)) || undefined + connection = (await this.connectionService.findByVerkey(recipientKey)) || undefined // We check whether the sender key is the same as the key we have stored in the connection // otherwise everyone could send messages to our key and we would just accept @@ -80,18 +82,17 @@ export class MessageReceiver { const messageContext = new InboundMessageContext(message, { connection, senderVerkey: senderKey, - recipientVerkey: unpackedMessage.recipient_verkey, + recipientVerkey: recipientKey, }) // We want to save a session if there is a chance of returning outbound message via inbound transport. // That can happen when inbound message has `return_route` set to `all` or `thread`. // If `return_route` defines just `thread`, we decide later whether to use session according to outbound message `threadId`. - if (connection && message.hasAnyReturnRoute() && session) { + if (senderKey && recipientKey && message.hasAnyReturnRoute() && session) { const keys = { - // TODO handle the case when senderKey is missing - recipientKeys: senderKey ? [senderKey] : [], + recipientKeys: [senderKey], routingKeys: [], - senderKey: connection?.verkey || null, + senderKey: recipientKey, } session.keys = keys session.inboundMessage = message @@ -99,7 +100,7 @@ export class MessageReceiver { this.transportService.saveSession(session) } - return await this.dispatcher.dispatch(messageContext) + await this.dispatcher.dispatch(messageContext) } /** @@ -108,7 +109,7 @@ export class MessageReceiver { * * @param packedMessage the received, probably packed, message to unpack */ - private async unpackMessage(packedMessage: Record): Promise { + private async unpackMessage(packedMessage: WireMessage): Promise { // If the inbound message has no @type field we assume // the message is packed and must be unpacked first if (!this.isUnpackedMessage(packedMessage)) { diff --git a/packages/core/src/agent/MessageSender.ts b/packages/core/src/agent/MessageSender.ts index 9303bb8e68..6375994e78 100644 --- a/packages/core/src/agent/MessageSender.ts +++ b/packages/core/src/agent/MessageSender.ts @@ -1,6 +1,9 @@ +import type { DidCommService, ConnectionRecord } from '../modules/connections' import type { OutboundTransporter } from '../transport/OutboundTransporter' -import type { OutboundMessage, OutboundPackage } from '../types' +import type { OutboundMessage, OutboundPackage, WireMessage } from '../types' +import type { AgentMessage } from './AgentMessage' import type { EnvelopeKeys } from './EnvelopeService' +import type { TransportSession } from './TransportService' import { inject, Lifecycle, scoped } from 'tsyringe' @@ -12,7 +15,6 @@ import { MessageRepository } from '../storage/MessageRepository' import { EnvelopeService } from './EnvelopeService' import { TransportService } from './TransportService' -import { isUnpackedPackedMessage } from './helpers' @scoped(Lifecycle.ContainerScoped) export class MessageSender { @@ -42,103 +44,151 @@ export class MessageSender { return this._outboundTransporter } - public async packMessage(outboundMessage: OutboundMessage, keys: EnvelopeKeys): Promise { - const { connection, payload } = outboundMessage - const wireMessage = await this.envelopeService.packMessage(payload, keys) - return { connection, payload: wireMessage } - } + public async packMessage({ + keys, + message, + endpoint, + }: { + keys: EnvelopeKeys + message: AgentMessage + endpoint: string + }): Promise { + const wireMessage = await this.envelopeService.packMessage(message, keys) - public async sendMessage(outboundMessage: OutboundMessage | OutboundPackage) { - if (!this.outboundTransporter) { - throw new AriesFrameworkError('Agent has no outbound transporter!') + return { + payload: wireMessage, + responseRequested: message.hasAnyReturnRoute(), + endpoint, } + } - const { connection } = outboundMessage - const { id, verkey, theirKey } = connection - this.logger.debug('Send outbound message', { - connection: { id, verkey, theirKey }, - isUnpackedMessage: isUnpackedPackedMessage(outboundMessage), - message: outboundMessage.payload, - messageType: isUnpackedPackedMessage(outboundMessage) ? outboundMessage.payload.type : 'unknown', + private async sendMessageToSession(session: TransportSession, message: AgentMessage) { + this.logger.debug(`Existing ${session.type} transport session has been found.`, { + keys: session.keys, }) + if (!session.keys) { + throw new AriesFrameworkError(`There are no keys for the given ${session.type} transport session.`) + } + const wireMessage = await this.envelopeService.packMessage(message, session.keys) - const threadId = isUnpackedPackedMessage(outboundMessage) ? outboundMessage.payload.threadId : undefined + await session.send(wireMessage) + } - // Try sending over already open connection + public async sendPackage({ + connection, + packedMessage, + }: { + connection: ConnectionRecord + packedMessage: WireMessage + }) { + // Try to send to already open session const session = this.transportService.findSessionByConnectionId(connection.id) - if (session?.inboundMessage?.hasReturnRouting(threadId)) { - this.logger.debug(`Existing ${session.type} transport session has been found.`) + if (session?.inboundMessage?.hasReturnRouting()) { try { - let outboundPackage: OutboundPackage - - // If outboundPackage is instance of AgentMessage we still need to pack - if (isUnpackedPackedMessage(outboundMessage)) { - if (!session.keys) { - throw new AriesFrameworkError(`There are no keys for the given ${session.type} transport session.`) - } - outboundPackage = await this.packMessage(outboundMessage, session.keys) - } - // Otherwise we use the message that is already packed. This is often not the case - // but happens with forwarding packed message - else { - outboundPackage = outboundMessage - } - - await session.send(outboundPackage) + await session.send(packedMessage) return } catch (error) { - this.logger.info(`Sending an outbound message via session failed with error: ${error.message}.`, error) + this.logger.info(`Sending packed message via session failed with error: ${error.message}.`, error) } } - const services = this.transportService.findDidCommServices(connection) - if (services.length === 0) { - throw new AriesFrameworkError(`Connection with id ${connection.id} has no service!`) - } + // Retrieve DIDComm services + const allServices = this.transportService.findDidCommServices(connection) + const reachableServices = allServices.filter((s) => !isDidCommTransportQueue(s.serviceEndpoint)) + const queueService = allServices.find((s) => isDidCommTransportQueue(s.serviceEndpoint)) this.logger.debug( - `Found ${services.length} services for message to connection '${connection.id}' (${connection.theirLabel})` + `Found ${allServices.length} services for message to connection '${connection.id}' (${connection.theirLabel})` ) - for await (const service of services) { - // We can't send message to didcomm:transport/queue - if (service.serviceEndpoint === DID_COMM_TRANSPORT_QUEUE) { - this.logger.debug(`Skipping transport queue service for connection '${connection.id}'`, { - service, + if (!this.outboundTransporter) { + throw new AriesFrameworkError('Agent has no outbound transporter!') + } + + // Loop trough all available services and try to send the message + for await (const service of reachableServices) { + this.logger.debug(`Sending outbound message to service:`, { service }) + try { + await this.outboundTransporter.sendMessage({ + payload: packedMessage, + endpoint: service.serviceEndpoint, }) - continue + return + } catch (error) { + this.logger.debug( + `Sending outbound message to service with id ${service.id} failed with the following error:`, + { + message: error.message, + error: error, + } + ) } + } + + // We didn't succeed to send the message over open session, or directly to serviceEndpoint + // If the other party shared a queue service endpoint in their did doc we queue the message + if (queueService) { + this.logger.debug(`Queue packed message for connection ${connection.id} (${connection.theirLabel})`) + this.messageRepository.add(connection.id, packedMessage) + return + } + + // Message is undeliverable + this.logger.error(`Message is undeliverable to connection ${connection.id} (${connection.theirLabel})`, { + message: packedMessage, + connection, + }) + throw new AriesFrameworkError(`Message is undeliverable to connection ${connection.id} (${connection.theirLabel})`) + } + + public async sendMessage(outboundMessage: OutboundMessage) { + if (!this.outboundTransporter) { + throw new AriesFrameworkError('Agent has no outbound transporter!') + } + + const { connection, payload } = outboundMessage + + this.logger.debug('Send outbound message', { + message: payload, + connectionId: connection.id, + }) - this.logger.debug(`Sending outbound message to service:`, { connectionId: connection.id, service }) + // Try to send to already open session + const session = this.transportService.findSessionByConnectionId(connection.id) + if (session?.inboundMessage?.hasReturnRouting(payload.threadId)) { try { - let outboundPackage: OutboundPackage - - // If outboundPackage is instance of AgentMessage we still need to pack - if (isUnpackedPackedMessage(outboundMessage)) { - const keys = { - recipientKeys: service.recipientKeys, - routingKeys: service.routingKeys || [], - senderKey: connection.verkey, - } + await this.sendMessageToSession(session, payload) + return + } catch (error) { + this.logger.info(`Sending an outbound message via session failed with error: ${error.message}.`, error) + } + } - // Set return routing for message if we don't have an inbound endpoint for this connection - if (!this.transportService.hasInboundEndpoint(outboundMessage.connection.didDoc)) { - outboundMessage.payload.setReturnRouting(ReturnRouteTypes.all) - } + // Retrieve DIDComm services + const allServices = this.transportService.findDidCommServices(connection) + const reachableServices = allServices.filter((s) => !isDidCommTransportQueue(s.serviceEndpoint)) + const queueService = allServices.find((s) => isDidCommTransportQueue(s.serviceEndpoint)) - outboundPackage = await this.packMessage(outboundMessage, keys) - outboundPackage.responseRequested = outboundMessage.payload.hasReturnRouting() - } else { - outboundPackage = outboundMessage - } + this.logger.debug( + `Found ${allServices.length} services for message to connection '${connection.id}' (${connection.theirLabel})` + ) - outboundPackage.endpoint = service.serviceEndpoint - await this.outboundTransporter.sendMessage(outboundPackage) + // Loop trough all available services and try to send the message + for await (const service of reachableServices) { + try { + // Enable return routing if the + const shouldUseReturnRoute = !this.transportService.hasInboundEndpoint(connection.didDoc) + await this.sendMessageToService({ + message: payload, + service, + senderKey: connection.verkey, + returnRoute: shouldUseReturnRoute, + }) return } catch (error) { this.logger.debug( - `Preparing outbound message to service with id ${service.id} failed with the following error:`, + `Sending outbound message to service with id ${service.id} failed with the following error:`, { message: error.message, error: error, @@ -149,18 +199,61 @@ export class MessageSender { // We didn't succeed to send the message over open session, or directly to serviceEndpoint // If the other party shared a queue service endpoint in their did doc we queue the message - const queueService = services.find((s) => s.serviceEndpoint === DID_COMM_TRANSPORT_QUEUE) - if ( - queueService && - // FIXME: we can't currently add unpacked message to the queue. This is good for now - // as forward messages are always packed. Allowing unpacked messages means - // we can queue undeliverable messages - !isUnpackedPackedMessage(outboundMessage) - ) { - this.logger.debug( - `Queue message for connection ${outboundMessage.connection.id} (${outboundMessage.connection.theirLabel})` - ) - this.messageRepository.add(outboundMessage.connection.id, outboundMessage.payload) + if (queueService) { + this.logger.debug(`Queue message for connection ${connection.id} (${connection.theirLabel})`) + + const keys = { + recipientKeys: queueService.recipientKeys, + routingKeys: queueService.routingKeys || [], + senderKey: connection.verkey, + } + + const wireMessage = await this.envelopeService.packMessage(payload, keys) + this.messageRepository.add(connection.id, wireMessage) + return + } + + // Message is undeliverable + this.logger.error(`Message is undeliverable to connection ${connection.id} (${connection.theirLabel})`, { + message: payload, + connection, + }) + throw new AriesFrameworkError(`Message is undeliverable to connection ${connection.id} (${connection.theirLabel})`) + } + + public async sendMessageToService({ + message, + service, + senderKey, + returnRoute, + }: { + message: AgentMessage + service: DidCommService + senderKey: string + returnRoute?: boolean + }) { + if (!this.outboundTransporter) { + throw new AriesFrameworkError('Agent has no outbound transporter!') } + + this.logger.debug(`Sending outbound message to service:`, { messageId: message.id, service }) + + const keys = { + recipientKeys: service.recipientKeys, + routingKeys: service.routingKeys || [], + senderKey, + } + + // Set return routing for message if requested + if (returnRoute) { + message.setReturnRouting(ReturnRouteTypes.all) + } + + const outboundPackage = await this.packMessage({ message, keys, endpoint: service.serviceEndpoint }) + await this.outboundTransporter.sendMessage(outboundPackage) } } + +export function isDidCommTransportQueue(serviceEndpoint: string): serviceEndpoint is typeof DID_COMM_TRANSPORT_QUEUE { + return serviceEndpoint === DID_COMM_TRANSPORT_QUEUE +} diff --git a/packages/core/src/agent/TransportService.ts b/packages/core/src/agent/TransportService.ts index a82d531408..01f784342a 100644 --- a/packages/core/src/agent/TransportService.ts +++ b/packages/core/src/agent/TransportService.ts @@ -1,6 +1,6 @@ import type { DidDoc, IndyAgentService } from '../modules/connections/models' import type { ConnectionRecord } from '../modules/connections/repository' -import type { OutboundPackage } from '../types' +import type { WireMessage } from '../types' import type { AgentMessage } from './AgentMessage' import type { EnvelopeKeys } from './EnvelopeService' @@ -64,5 +64,5 @@ export interface TransportSession { keys?: EnvelopeKeys inboundMessage?: AgentMessage connection?: ConnectionRecord - send(outboundMessage: OutboundPackage): Promise + send(wireMessage: WireMessage): Promise } diff --git a/packages/core/src/agent/__tests__/MessageSender.test.ts b/packages/core/src/agent/__tests__/MessageSender.test.ts index d101662775..ac39a4aea8 100644 --- a/packages/core/src/agent/__tests__/MessageSender.test.ts +++ b/packages/core/src/agent/__tests__/MessageSender.test.ts @@ -1,7 +1,7 @@ import type { ConnectionRecord } from '../../modules/connections' import type { MessageRepository } from '../../storage/MessageRepository' import type { OutboundTransporter } from '../../transport' -import type { OutboundMessage } from '../../types' +import type { OutboundMessage, WireMessage } from '../../types' import { getAgentConfig, getMockConnection, mockFunction } from '../../../tests/helpers' import testLogger from '../../../tests/logger' @@ -41,13 +41,11 @@ class DummyOutboundTransporter implements OutboundTransporter { describe('MessageSender', () => { const EnvelopeService = >(EnvelopeServiceImpl) - const wireMessage = { - alg: 'EC', - crv: 'P-256', - x: 'MKBCTNIcKUSDii11ySs3526iDZ8AiTo7Tu6KPAqv7D4', - y: '4Etl6SRW2YiLUrN5vfvVHuhp7x8PxltmWWlbbM4IFyM', - use: 'enc', - kid: '1', + const wireMessage: WireMessage = { + protected: 'base64url', + iv: 'base64url', + ciphertext: 'base64url', + tag: 'base64url', } const enveloperService = new EnvelopeService() @@ -98,7 +96,7 @@ describe('MessageSender', () => { outboundTransporter = new DummyOutboundTransporter() messageRepository = new InMemoryMessageRepository(getAgentConfig('MessageSender')) messageSender = new MessageSender(enveloperService, transportService, messageRepository, logger) - connection = getMockConnection({ id: 'test-123' }) + connection = getMockConnection({ id: 'test-123', theirLabel: 'Test 123' }) outboundMessage = createOutboundMessage(connection, new AgentMessage()) @@ -114,12 +112,12 @@ describe('MessageSender', () => { await expect(messageSender.sendMessage(outboundMessage)).rejects.toThrow(`Agent has no outbound transporter!`) }) - test('throw error when there is no service', async () => { + test('throw error when there is no service or queue', async () => { messageSender.setOutboundTransporter(outboundTransporter) transportServiceFindServicesMock.mockReturnValue([]) await expect(messageSender.sendMessage(outboundMessage)).rejects.toThrow( - `Connection with id test-123 has no service!` + `Message is undeliverable to connection test-123 (Test 123)` ) }) @@ -134,7 +132,6 @@ describe('MessageSender', () => { await messageSender.sendMessage(outboundMessage) expect(sendMessageSpy).toHaveBeenCalledWith({ - connection, payload: wireMessage, endpoint: firstDidCommService.serviceEndpoint, responseRequested: false, @@ -152,7 +149,6 @@ describe('MessageSender', () => { await messageSender.sendMessage(outboundMessage) expect(sendMessageSpy).toHaveBeenCalledWith({ - connection, payload: wireMessage, endpoint: firstDidCommService.serviceEndpoint, responseRequested: false, @@ -163,48 +159,91 @@ describe('MessageSender', () => { test('call send message on session when there is a session for a given connection', async () => { messageSender.setOutboundTransporter(outboundTransporter) const sendMessageSpy = jest.spyOn(outboundTransporter, 'sendMessage') - session.connection = connection - transportServiceFindSessionMock.mockReturnValue(session) + const sendMessageToServiceSpy = jest.spyOn(messageSender, 'sendMessageToService') await messageSender.sendMessage(outboundMessage) - expect(session.send).toHaveBeenCalledWith({ - connection, - payload: wireMessage, + expect(sendMessageToServiceSpy).toHaveBeenCalledWith({ + message: outboundMessage.payload, + senderKey: connection.verkey, + service: firstDidCommService, + returnRoute: false, }) - expect(sendMessageSpy).toHaveBeenCalledTimes(0) + expect(sendMessageToServiceSpy).toHaveBeenCalledTimes(1) + expect(sendMessageSpy).toHaveBeenCalledTimes(1) }) - test('call send message with connection, payload and endpoint from first DidComm service', async () => { + test('calls sendMessageToService with payload and endpoint from second DidComm service when the first fails', async () => { messageSender.setOutboundTransporter(outboundTransporter) const sendMessageSpy = jest.spyOn(outboundTransporter, 'sendMessage') + const sendMessageToServiceSpy = jest.spyOn(messageSender, 'sendMessageToService') + + // Simulate the case when the first call fails + sendMessageSpy.mockRejectedValueOnce(new Error()) await messageSender.sendMessage(outboundMessage) - expect(sendMessageSpy).toHaveBeenCalledWith({ - connection, - payload: wireMessage, - endpoint: firstDidCommService.serviceEndpoint, - responseRequested: false, + + expect(sendMessageToServiceSpy).toHaveBeenNthCalledWith(2, { + message: outboundMessage.payload, + senderKey: connection.verkey, + service: secondDidCommService, + returnRoute: false, }) - expect(sendMessageSpy).toHaveBeenCalledTimes(1) + expect(sendMessageToServiceSpy).toHaveBeenCalledTimes(2) + expect(sendMessageSpy).toHaveBeenCalledTimes(2) }) + }) - test('call send message with connection, payload and endpoint from second DidComm service when the first fails', async () => { + describe('sendMessageToService', () => { + const service = new DidCommService({ + id: 'out-of-band', + recipientKeys: ['someKey'], + serviceEndpoint: 'https://example.com', + }) + const senderKey = 'someVerkey' + + beforeEach(() => { + outboundTransporter = new DummyOutboundTransporter() + messageSender = new MessageSender( + enveloperService, + transportService, + new InMemoryMessageRepository(getAgentConfig('MessageSenderTest')), + logger + ) + + envelopeServicePackMessageMock.mockReturnValue(Promise.resolve(wireMessage)) + }) + + afterEach(() => { + jest.resetAllMocks() + }) + + test('throws error when there is no outbound transport', async () => { + await expect( + messageSender.sendMessageToService({ + message: new AgentMessage(), + senderKey, + service, + }) + ).rejects.toThrow(`Agent has no outbound transporter!`) + }) + + test('calls send message with payload and endpoint from DIDComm service', async () => { messageSender.setOutboundTransporter(outboundTransporter) const sendMessageSpy = jest.spyOn(outboundTransporter, 'sendMessage') - // Simulate the case when the first call fails - sendMessageSpy.mockRejectedValueOnce(new Error()) - - await messageSender.sendMessage(outboundMessage) + await messageSender.sendMessageToService({ + message: new AgentMessage(), + senderKey, + service, + }) - expect(sendMessageSpy).toHaveBeenNthCalledWith(2, { - connection, + expect(sendMessageSpy).toHaveBeenCalledWith({ payload: wireMessage, - endpoint: secondDidCommService.serviceEndpoint, + endpoint: service.serviceEndpoint, responseRequested: false, }) - expect(sendMessageSpy).toHaveBeenCalledTimes(2) + expect(sendMessageSpy).toHaveBeenCalledTimes(1) }) test('call send message with responseRequested when message has return route', async () => { @@ -213,14 +252,16 @@ describe('MessageSender', () => { const message = new AgentMessage() message.setReturnRouting(ReturnRouteTypes.all) - const outboundMessage = createOutboundMessage(connection, message) - await messageSender.sendMessage(outboundMessage) + await messageSender.sendMessageToService({ + message, + senderKey, + service, + }) expect(sendMessageSpy).toHaveBeenCalledWith({ - connection, payload: wireMessage, - endpoint: firstDidCommService.serviceEndpoint, + endpoint: service.serviceEndpoint, responseRequested: true, }) expect(sendMessageSpy).toHaveBeenCalledTimes(1) @@ -243,18 +284,19 @@ describe('MessageSender', () => { test('return outbound message context with connection, payload and endpoint', async () => { const message = new AgentMessage() - const outboundMessage = createOutboundMessage(connection, message) + const endpoint = 'https://example.com' const keys = { recipientKeys: ['service.recipientKeys'], routingKeys: [], senderKey: connection.verkey, } - const result = await messageSender.packMessage(outboundMessage, keys) + const result = await messageSender.packMessage({ message, keys, endpoint }) expect(result).toEqual({ - connection, payload: wireMessage, + responseRequested: message.hasAnyReturnRoute(), + endpoint, }) }) }) diff --git a/packages/core/src/agent/helpers.ts b/packages/core/src/agent/helpers.ts index 6b87e35c6a..314011abad 100644 --- a/packages/core/src/agent/helpers.ts +++ b/packages/core/src/agent/helpers.ts @@ -1,7 +1,8 @@ import type { ConnectionRecord } from '../modules/connections' -import type { OutboundMessage, OutboundPackage } from '../types' +import type { OutboundMessage, OutboundServiceMessage } from '../types' +import type { AgentMessage } from './AgentMessage' -import { AgentMessage } from './AgentMessage' +import { DidCommService } from '../modules/connections/models/did/service/DidCommService' export function createOutboundMessage( connection: ConnectionRecord, @@ -13,8 +14,16 @@ export function createOutboundMessage( } } -export function isUnpackedPackedMessage( - outboundMessage: OutboundMessage | OutboundPackage -): outboundMessage is OutboundMessage { - return outboundMessage.payload instanceof AgentMessage +export function createOutboundServiceMessage(options: { + payload: T + service: DidCommService + senderKey: string +}): OutboundServiceMessage { + return options +} + +export function isOutboundServiceMessage( + message: OutboundMessage | OutboundServiceMessage +): message is OutboundServiceMessage { + return (message as OutboundServiceMessage).service instanceof DidCommService } diff --git a/packages/core/src/agent/models/InboundMessageContext.ts b/packages/core/src/agent/models/InboundMessageContext.ts index 9ea9f1970f..29e3703025 100644 --- a/packages/core/src/agent/models/InboundMessageContext.ts +++ b/packages/core/src/agent/models/InboundMessageContext.ts @@ -19,15 +19,8 @@ export class InboundMessageContext { public constructor(message: T, context: MessageContextParams = {}) { this.message = message this.recipientVerkey = context.recipientVerkey - - if (context.connection) { - this.connection = context.connection - // TODO: which senderkey should we prioritize - // Or should we throw an error when they don't match? - this.senderVerkey = context.connection.theirKey || context.senderVerkey || undefined - } else if (context.senderVerkey) { - this.senderVerkey = context.senderVerkey - } + this.senderVerkey = context.senderVerkey + this.connection = context.connection } /** diff --git a/packages/core/src/decorators/service/ServiceDecorator.test.ts b/packages/core/src/decorators/service/ServiceDecorator.test.ts new file mode 100644 index 0000000000..9ee654bab5 --- /dev/null +++ b/packages/core/src/decorators/service/ServiceDecorator.test.ts @@ -0,0 +1,33 @@ +import { BaseMessage } from '../../agent/BaseMessage' +import { JsonTransformer } from '../../utils/JsonTransformer' +import { Compose } from '../../utils/mixins' + +import { ServiceDecorated } from './ServiceDecoratorExtension' + +describe('Decorators | ServiceDecoratorExtension', () => { + class TestMessage extends Compose(BaseMessage, [ServiceDecorated]) { + public toJSON(): Record { + return JsonTransformer.toJSON(this) + } + } + + const service = { + recipientKeys: ['test', 'test'], + routingKeys: ['test', 'test'], + serviceEndpoint: 'https://example.com', + } + + test('transforms ServiceDecorator class to JSON', () => { + const message = new TestMessage() + + message.setService(service) + expect(message.toJSON()).toEqual({ '~service': service }) + }) + + test('transforms Json to ServiceDecorator class', () => { + const transformed = JsonTransformer.fromJSON({ '~service': service }, TestMessage) + + expect(transformed.service).toEqual(service) + expect(transformed).toBeInstanceOf(TestMessage) + }) +}) diff --git a/packages/core/src/decorators/service/ServiceDecorator.ts b/packages/core/src/decorators/service/ServiceDecorator.ts new file mode 100644 index 0000000000..e00a8bcdf1 --- /dev/null +++ b/packages/core/src/decorators/service/ServiceDecorator.ts @@ -0,0 +1,47 @@ +import { IsArray, IsOptional, IsString } from 'class-validator' + +import { DidCommService } from '../../modules/connections/models/did/service/DidCommService' +import { uuid } from '../../utils/uuid' + +export interface ServiceDecoratorOptions { + recipientKeys: string[] + routingKeys?: string[] + serviceEndpoint: string +} + +/** + * Represents `~service` decorator + * + * Based on specification Aries RFC 0056: Service Decorator + * @see https://github.com/hyperledger/aries-rfcs/tree/master/features/0056-service-decorator + */ +export class ServiceDecorator { + public constructor(options: ServiceDecoratorOptions) { + if (options) { + this.recipientKeys = options.recipientKeys + this.routingKeys = options.routingKeys + this.serviceEndpoint = options.serviceEndpoint + } + } + + @IsArray() + @IsString({ each: true }) + public recipientKeys!: string[] + + @IsArray() + @IsString({ each: true }) + @IsOptional() + public routingKeys?: string[] + + @IsString() + public serviceEndpoint!: string + + public toDidCommService(id?: string) { + return new DidCommService({ + id: id ?? uuid(), + recipientKeys: this.recipientKeys, + routingKeys: this.routingKeys, + serviceEndpoint: this.serviceEndpoint, + }) + } +} diff --git a/packages/core/src/decorators/service/ServiceDecoratorExtension.ts b/packages/core/src/decorators/service/ServiceDecoratorExtension.ts new file mode 100644 index 0000000000..ecf2f9a044 --- /dev/null +++ b/packages/core/src/decorators/service/ServiceDecoratorExtension.ts @@ -0,0 +1,23 @@ +import type { BaseMessageConstructor } from '../../agent/BaseMessage' +import type { ServiceDecoratorOptions } from './ServiceDecorator' + +import { Expose, Type } from 'class-transformer' +import { IsOptional, ValidateNested } from 'class-validator' + +import { ServiceDecorator } from './ServiceDecorator' + +export function ServiceDecorated(Base: T) { + class ServiceDecoratorExtension extends Base { + @Expose({ name: '~service' }) + @Type(() => ServiceDecorator) + @IsOptional() + @ValidateNested() + public service?: ServiceDecorator + + public setService(serviceData: ServiceDecoratorOptions) { + this.service = new ServiceDecorator(serviceData) + } + } + + return ServiceDecoratorExtension +} diff --git a/packages/core/src/decorators/transport/TransportDecoratorExtension.ts b/packages/core/src/decorators/transport/TransportDecoratorExtension.ts index 0a30b008b7..f886eefdcc 100644 --- a/packages/core/src/decorators/transport/TransportDecoratorExtension.ts +++ b/packages/core/src/decorators/transport/TransportDecoratorExtension.ts @@ -34,7 +34,7 @@ export function TransportDecorated(Base: T) { public hasAnyReturnRoute() { const returnRoute = this.transport?.returnRoute - return returnRoute && (returnRoute === ReturnRouteTypes.all || returnRoute === ReturnRouteTypes.thread) + return returnRoute === ReturnRouteTypes.all || returnRoute === ReturnRouteTypes.thread } } diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 68cde99019..750408f90e 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -4,7 +4,7 @@ import 'reflect-metadata' export { Agent } from './agent/Agent' export { AgentConfig } from './agent/AgentConfig' export type { AgentDependencies } from './agent/AgentDependencies' -export type { InitConfig, OutboundPackage } from './types' +export type { InitConfig, OutboundPackage, WireMessage } from './types' export { DidCommMimeType } from './types' export type { FileSystem } from './storage/FileSystem' export { InMemoryMessageRepository } from './storage/InMemoryMessageRepository' diff --git a/packages/core/src/modules/connections/__tests__/ConnectionService.test.ts b/packages/core/src/modules/connections/__tests__/ConnectionService.test.ts index b02c77b2f1..d273f2bb85 100644 --- a/packages/core/src/modules/connections/__tests__/ConnectionService.test.ts +++ b/packages/core/src/modules/connections/__tests__/ConnectionService.test.ts @@ -2,6 +2,7 @@ import type { Wallet } from '../../../wallet/Wallet' import type { Did } from 'indy-sdk' import { getAgentConfig, getMockConnection, mockFunction } from '../../../../tests/helpers' +import { AgentMessage } from '../../../agent/AgentMessage' import { EventEmitter } from '../../../agent/EventEmitter' import { InboundMessageContext } from '../../../agent/models/InboundMessageContext' import { SignatureDecorator } from '../../../decorators/signature/SignatureDecorator' @@ -22,7 +23,6 @@ import { ConnectionRepository } from '../repository/ConnectionRepository' import { ConnectionService } from '../services/ConnectionService' jest.mock('../repository/ConnectionRepository') - const ConnectionRepositoryMock = ConnectionRepository as jest.Mock describe('ConnectionService', () => { @@ -710,6 +710,175 @@ describe('ConnectionService', () => { }) }) + describe('assertConnectionOrServiceDecorator', () => { + it('should not throw an error when a connection record with state complete is present in the messageContext', () => { + expect.assertions(1) + + const messageContext = new InboundMessageContext(new AgentMessage(), { + connection: getMockConnection({ state: ConnectionState.Complete }), + }) + + expect(() => connectionService.assertConnectionOrServiceDecorator(messageContext)).not.toThrow() + }) + + it('should throw an error when a connection record is present and state not complete in the messageContext', () => { + expect.assertions(1) + + const messageContext = new InboundMessageContext(new AgentMessage(), { + connection: getMockConnection({ state: ConnectionState.Invited }), + }) + + expect(() => connectionService.assertConnectionOrServiceDecorator(messageContext)).toThrowError( + 'Connection record is not ready to be used' + ) + }) + + it('should not throw an error when no connection record is present in the messageContext and no additional data, but the message has a ~service decorator', () => { + expect.assertions(1) + + const message = new AgentMessage() + message.setService({ + recipientKeys: [], + serviceEndpoint: '', + routingKeys: [], + }) + const messageContext = new InboundMessageContext(message) + + expect(() => connectionService.assertConnectionOrServiceDecorator(messageContext)).not.toThrow() + }) + + it('should not throw when a fully valid connection-less input is passed', () => { + expect.assertions(1) + + const senderKey = 'senderKey' + const recipientKey = 'recipientKey' + + const previousSentMessage = new AgentMessage() + previousSentMessage.setService({ + recipientKeys: [recipientKey], + serviceEndpoint: '', + routingKeys: [], + }) + + const previousReceivedMessage = new AgentMessage() + previousReceivedMessage.setService({ + recipientKeys: [senderKey], + serviceEndpoint: '', + routingKeys: [], + }) + + const message = new AgentMessage() + message.setService({ + recipientKeys: [], + serviceEndpoint: '', + routingKeys: [], + }) + const messageContext = new InboundMessageContext(message, { + recipientVerkey: recipientKey, + senderVerkey: senderKey, + }) + + expect(() => + connectionService.assertConnectionOrServiceDecorator(messageContext, { + previousReceivedMessage, + previousSentMessage, + }) + ).not.toThrow() + }) + + it('should throw an error when previousSentMessage is present, but recipientVerkey is not ', () => { + expect.assertions(1) + + const previousSentMessage = new AgentMessage() + previousSentMessage.setService({ + recipientKeys: [], + serviceEndpoint: '', + routingKeys: [], + }) + + const message = new AgentMessage() + const messageContext = new InboundMessageContext(message) + + expect(() => + connectionService.assertConnectionOrServiceDecorator(messageContext, { + previousSentMessage, + }) + ).toThrowError('Cannot verify service without recipientKey on incoming message') + }) + + it('should throw an error when previousSentMessage and recipientKey are present, but recipient key is not present in recipientKeys of previously sent message ~service decorator', () => { + expect.assertions(1) + + const recipientKey = 'recipientKey' + + const previousSentMessage = new AgentMessage() + previousSentMessage.setService({ + recipientKeys: ['anotherKey'], + serviceEndpoint: '', + routingKeys: [], + }) + + const message = new AgentMessage() + const messageContext = new InboundMessageContext(message, { + recipientVerkey: recipientKey, + }) + + expect(() => + connectionService.assertConnectionOrServiceDecorator(messageContext, { + previousSentMessage, + }) + ).toThrowError( + 'Previously sent message ~service recipientKeys does not include current received message recipient key' + ) + }) + + it('should throw an error when previousReceivedMessage is present, but senderVerkey is not ', () => { + expect.assertions(1) + + const previousReceivedMessage = new AgentMessage() + previousReceivedMessage.setService({ + recipientKeys: [], + serviceEndpoint: '', + routingKeys: [], + }) + + const message = new AgentMessage() + const messageContext = new InboundMessageContext(message) + + expect(() => + connectionService.assertConnectionOrServiceDecorator(messageContext, { + previousReceivedMessage, + }) + ).toThrowError('Cannot verify service without senderKey on incoming message') + }) + + it('should throw an error when previousReceivedMessage and senderKey are present, but sender key is not present in recipientKeys of previously received message ~service decorator', () => { + expect.assertions(1) + + const senderKey = 'senderKey' + + const previousReceivedMessage = new AgentMessage() + previousReceivedMessage.setService({ + recipientKeys: ['anotherKey'], + serviceEndpoint: '', + routingKeys: [], + }) + + const message = new AgentMessage() + const messageContext = new InboundMessageContext(message, { + senderVerkey: senderKey, + }) + + expect(() => + connectionService.assertConnectionOrServiceDecorator(messageContext, { + previousReceivedMessage, + }) + ).toThrowError( + 'Previously received message ~service recipientKeys does not include current received message sender key' + ) + }) + }) + describe('repository methods', () => { it('getById should return value from connectionRepository.getById', async () => { const expected = getMockConnection() diff --git a/packages/core/src/modules/connections/index.ts b/packages/core/src/modules/connections/index.ts index 3c8adf3337..844d3c2c93 100644 --- a/packages/core/src/modules/connections/index.ts +++ b/packages/core/src/modules/connections/index.ts @@ -1,5 +1,6 @@ export * from './messages' export * from './models' export * from './repository' +export * from './services' export * from './ConnectionEvents' export * from './ConnectionsModule' diff --git a/packages/core/src/modules/connections/services/ConnectionService.ts b/packages/core/src/modules/connections/services/ConnectionService.ts index f1c3c660c8..5c32fedd3d 100644 --- a/packages/core/src/modules/connections/services/ConnectionService.ts +++ b/packages/core/src/modules/connections/services/ConnectionService.ts @@ -1,5 +1,6 @@ import type { AgentMessage } from '../../../agent/AgentMessage' import type { InboundMessageContext } from '../../../agent/models/InboundMessageContext' +import type { Logger } from '../../../logger' import type { AckMessage } from '../../common' import type { ConnectionStateChangedEvent } from '../ConnectionEvents' import type { CustomConnectionTags } from '../repository/ConnectionRecord' @@ -41,6 +42,7 @@ export class ConnectionService { private config: AgentConfig private connectionRepository: ConnectionRepository private eventEmitter: EventEmitter + private logger: Logger public constructor( @inject(InjectionSymbols.Wallet) wallet: Wallet, @@ -52,6 +54,7 @@ export class ConnectionService { this.config = config this.connectionRepository = connectionRepository this.eventEmitter = eventEmitter + this.logger = config.logger } /** @@ -343,6 +346,83 @@ export class ConnectionService { return connection } + /** + * Assert that an inbound message either has a connection associated with it, + * or has everything correctly set up for connection-less exchange. + * + * @param messageContext - the inbound message context + * @param previousRespondence - previous sent and received message to determine if a valid service decorator is present + */ + public assertConnectionOrServiceDecorator( + messageContext: InboundMessageContext, + { + previousSentMessage, + previousReceivedMessage, + }: { + previousSentMessage?: AgentMessage + previousReceivedMessage?: AgentMessage + } = {} + ) { + const { connection, message } = messageContext + + // Check if we have a ready connection. Verification is already done somewhere else. Return + if (connection) { + connection.assertReady() + this.logger.debug(`Processing message with id ${message.id} and connection id ${connection.id}`, { + type: message.type, + }) + } else { + this.logger.debug(`Processing connection-less message with id ${message.id}`, { + type: message.type, + }) + + if (previousSentMessage) { + // If we have previously sent a message, it is not allowed to receive an OOB/unpacked message + if (!messageContext.recipientVerkey) { + throw new AriesFrameworkError( + 'Cannot verify service without recipientKey on incoming message (received unpacked message)' + ) + } + + // Check if the inbound message recipient key is present + // in the recipientKeys of previously sent message ~service decorator + if ( + !previousSentMessage?.service || + !previousSentMessage.service.recipientKeys.includes(messageContext.recipientVerkey) + ) { + throw new AriesFrameworkError( + 'Previously sent message ~service recipientKeys does not include current received message recipient key' + ) + } + } + + if (previousReceivedMessage) { + // If we have previously received a message, it is not allowed to receive an OOB/unpacked/AnonCrypt message + if (!messageContext.senderVerkey) { + throw new AriesFrameworkError( + 'Cannot verify service without senderKey on incoming message (received AnonCrypt or unpacked message)' + ) + } + + // Check if the inbound message sender key is present + // in the recipientKeys of previously received message ~service decorator + if ( + !previousReceivedMessage.service || + !previousReceivedMessage.service.recipientKeys.includes(messageContext.senderVerkey) + ) { + throw new AriesFrameworkError( + 'Previously received message ~service recipientKeys does not include current received message sender key' + ) + } + } + + // If message is received unpacked/, we need to make sure it included a ~service decorator + if (!message.service && (!messageContext.senderVerkey || !messageContext.recipientVerkey)) { + throw new AriesFrameworkError('Message without senderKey and recipientKey must have ~service decorator') + } + } + } + public async updateState(connectionRecord: ConnectionRecord, newState: ConnectionState) { const previousState = connectionRecord.state connectionRecord.state = newState diff --git a/packages/core/src/modules/credentials/CredentialsModule.ts b/packages/core/src/modules/credentials/CredentialsModule.ts index d7fb069ac5..0cafc9c976 100644 --- a/packages/core/src/modules/credentials/CredentialsModule.ts +++ b/packages/core/src/modules/credentials/CredentialsModule.ts @@ -1,19 +1,19 @@ import type { AutoAcceptCredential } from './CredentialAutoAcceptType' -import type { CredentialPreview } from './messages' +import type { OfferCredentialMessage, CredentialPreview } from './messages' import type { CredentialRecord } from './repository/CredentialRecord' import type { CredentialOfferTemplate, CredentialProposeOptions } from './services' -import { inject, Lifecycle, scoped } from 'tsyringe' +import { Lifecycle, scoped } from 'tsyringe' import { AgentConfig } from '../../agent/AgentConfig' import { Dispatcher } from '../../agent/Dispatcher' import { MessageSender } from '../../agent/MessageSender' import { createOutboundMessage } from '../../agent/helpers' -import { InjectionSymbols } from '../../constants' +import { ServiceDecorator } from '../../decorators/service/ServiceDecorator' import { AriesFrameworkError } from '../../error' -import { Logger } from '../../logger' import { isLinkedAttachment } from '../../utils/attachment' import { ConnectionService } from '../connections/services/ConnectionService' +import { MediationRecipientService } from '../routing' import { CredentialResponseCoordinator } from './CredentialResponseCoordinator' import { @@ -32,7 +32,7 @@ export class CredentialsModule { private messageSender: MessageSender private agentConfig: AgentConfig private credentialResponseCoordinator: CredentialResponseCoordinator - private logger: Logger + private mediationRecipientService: MediationRecipientService public constructor( dispatcher: Dispatcher, @@ -41,14 +41,14 @@ export class CredentialsModule { messageSender: MessageSender, agentConfig: AgentConfig, credentialResponseCoordinator: CredentialResponseCoordinator, - @inject(InjectionSymbols.Logger) logger: Logger + mediationRecipientService: MediationRecipientService ) { this.connectionService = connectionService this.credentialService = credentialService this.messageSender = messageSender this.agentConfig = agentConfig this.credentialResponseCoordinator = credentialResponseCoordinator - this.logger = logger + this.mediationRecipientService = mediationRecipientService this.registerHandlers(dispatcher) } @@ -89,10 +89,15 @@ export class CredentialsModule { } ) { const credentialRecord = await this.credentialService.getById(credentialRecordId) + if (!credentialRecord.connectionId) { + throw new AriesFrameworkError( + `No connectionId found for credential record '${credentialRecord.id}'. Connection-less issuance does not support credential proposal or negotiation.` + ) + } + const connection = await this.connectionService.getById(credentialRecord.connectionId) const credentialProposalMessage = credentialRecord.proposalMessage - if (!credentialProposalMessage?.credentialProposal) { throw new AriesFrameworkError( `Credential record with id ${credentialRecordId} is missing required credential proposal` @@ -146,6 +151,12 @@ export class CredentialsModule { } ) { const credentialRecord = await this.credentialService.getById(credentialRecordId) + + if (!credentialRecord.connectionId) { + throw new AriesFrameworkError( + `No connectionId found for credential record '${credentialRecord.id}'. Connection-less issuance does not support negotiation.` + ) + } const connection = await this.connectionService.getById(credentialRecord.connectionId) const credentialProposalMessage = credentialRecord.proposalMessage @@ -192,7 +203,7 @@ export class CredentialsModule { ): Promise { const connection = await this.connectionService.getById(connectionId) - const { message, credentialRecord } = await this.credentialService.createOffer(connection, credentialTemplate) + const { message, credentialRecord } = await this.credentialService.createOffer(credentialTemplate, connection) const outboundMessage = createOutboundMessage(connection, message) await this.messageSender.sendMessage(outboundMessage) @@ -200,6 +211,34 @@ export class CredentialsModule { return credentialRecord } + /** + * Initiate a new credential exchange as issuer by creating a credential offer + * not bound to any connection. The offer must be delivered out-of-band to the holder + * + * @param credentialTemplate The credential template to use for the offer + * @returns The credential record and credential offer message + */ + public async createOutOfBandOffer(credentialTemplate: CredentialOfferTemplate): Promise<{ + offerMessage: OfferCredentialMessage + credentialRecord: CredentialRecord + }> { + const { message, credentialRecord } = await this.credentialService.createOffer(credentialTemplate) + + // Create and set ~service decorator + const routing = await this.mediationRecipientService.getRouting() + message.service = new ServiceDecorator({ + serviceEndpoint: routing.endpoint, + recipientKeys: [routing.verkey], + routingKeys: routing.routingKeys, + }) + + // Save ~service decorator to record (to remember our verkey) + credentialRecord.offerMessage = message + await this.credentialService.update(credentialRecord) + + return { credentialRecord, offerMessage: message } + } + /** * Accept a credential offer as holder (by sending a credential request message) to the connection * associated with the credential record. @@ -212,16 +251,58 @@ export class CredentialsModule { public async acceptOffer( credentialRecordId: string, config?: { comment?: string; autoAcceptCredential?: AutoAcceptCredential } - ) { - const credentialRecord = await this.credentialService.getById(credentialRecordId) - const connection = await this.connectionService.getById(credentialRecord.connectionId) + ): Promise { + const record = await this.credentialService.getById(credentialRecordId) - const { message } = await this.credentialService.createRequest(credentialRecord, config) + // Use connection if present + if (record.connectionId) { + const connection = await this.connectionService.getById(record.connectionId) - const outboundMessage = createOutboundMessage(connection, message) - await this.messageSender.sendMessage(outboundMessage) + const { message, credentialRecord } = await this.credentialService.createRequest(record, { + ...config, + holderDid: connection.did, + }) + const outboundMessage = createOutboundMessage(connection, message) - return credentialRecord + await this.messageSender.sendMessage(outboundMessage) + return credentialRecord + } + // Use ~service decorator otherwise + else if (record.offerMessage?.service) { + // Create ~service decorator + const routing = await this.mediationRecipientService.getRouting() + const ourService = new ServiceDecorator({ + serviceEndpoint: routing.endpoint, + recipientKeys: [routing.verkey], + routingKeys: routing.routingKeys, + }) + const recipientService = record.offerMessage.service + + const { message, credentialRecord } = await this.credentialService.createRequest(record, { + ...config, + holderDid: ourService.recipientKeys[0], + }) + + // Set and save ~service decorator to record (to remember our verkey) + message.service = ourService + credentialRecord.requestMessage = message + await this.credentialService.update(credentialRecord) + + await this.messageSender.sendMessageToService({ + message, + service: recipientService.toDidCommService(), + senderKey: ourService.recipientKeys[0], + returnRoute: true, + }) + + return credentialRecord + } + // Cannot send message without connectionId or ~service decorator + else { + throw new AriesFrameworkError( + `Cannot accept offer for credential record without connectionId or ~service decorator on credential offer.` + ) + } } /** @@ -240,6 +321,12 @@ export class CredentialsModule { config?: { comment?: string; autoAcceptCredential?: AutoAcceptCredential } ) { const credentialRecord = await this.credentialService.getById(credentialRecordId) + + if (!credentialRecord.connectionId) { + throw new AriesFrameworkError( + `No connectionId found for credential record '${credentialRecord.id}'. Connection-less issuance does not support negotiation.` + ) + } const connection = await this.connectionService.getById(credentialRecord.connectionId) const { message } = await this.credentialService.createProposalAsResponse(credentialRecord, { @@ -266,14 +353,39 @@ export class CredentialsModule { credentialRecordId: string, config?: { comment?: string; autoAcceptCredential?: AutoAcceptCredential } ) { - const credentialRecord = await this.credentialService.getById(credentialRecordId) - const connection = await this.connectionService.getById(credentialRecord.connectionId) + const record = await this.credentialService.getById(credentialRecordId) + const { message, credentialRecord } = await this.credentialService.createCredential(record, config) - this.logger.info(`Accepting request for credential record ${credentialRecordId}`) + // Use connection if present + if (credentialRecord.connectionId) { + const connection = await this.connectionService.getById(credentialRecord.connectionId) + const outboundMessage = createOutboundMessage(connection, message) - const { message } = await this.credentialService.createCredential(credentialRecord, config) - const outboundMessage = createOutboundMessage(connection, message) - await this.messageSender.sendMessage(outboundMessage) + await this.messageSender.sendMessage(outboundMessage) + } + // Use ~service decorator otherwise + else if (credentialRecord.requestMessage?.service && credentialRecord.offerMessage?.service) { + const recipientService = credentialRecord.requestMessage.service + const ourService = credentialRecord.offerMessage.service + + // Set ~service, update message in record (for later use) + message.setService(ourService) + credentialRecord.credentialMessage = message + await this.credentialService.update(credentialRecord) + + await this.messageSender.sendMessageToService({ + message, + service: recipientService.toDidCommService(), + senderKey: ourService.recipientKeys[0], + returnRoute: true, + }) + } + // Cannot send message without connectionId or ~service decorator + else { + throw new AriesFrameworkError( + `Cannot accept request for credential record without connectionId or ~service decorator on credential offer / request.` + ) + } return credentialRecord } @@ -287,12 +399,33 @@ export class CredentialsModule { * */ public async acceptCredential(credentialRecordId: string) { - const credentialRecord = await this.credentialService.getById(credentialRecordId) - const connection = await this.connectionService.getById(credentialRecord.connectionId) + const record = await this.credentialService.getById(credentialRecordId) + const { message, credentialRecord } = await this.credentialService.createAck(record) - const { message } = await this.credentialService.createAck(credentialRecord) - const outboundMessage = createOutboundMessage(connection, message) - await this.messageSender.sendMessage(outboundMessage) + if (credentialRecord.connectionId) { + const connection = await this.connectionService.getById(credentialRecord.connectionId) + const outboundMessage = createOutboundMessage(connection, message) + + await this.messageSender.sendMessage(outboundMessage) + } + // Use ~service decorator otherwise + else if (credentialRecord.credentialMessage?.service && credentialRecord.requestMessage?.service) { + const recipientService = credentialRecord.credentialMessage.service + const ourService = credentialRecord.requestMessage.service + + await this.messageSender.sendMessageToService({ + message, + service: recipientService.toDidCommService(), + senderKey: ourService.recipientKeys[0], + returnRoute: true, + }) + } + // Cannot send message without connectionId or ~service decorator + else { + throw new AriesFrameworkError( + `Cannot accept credential without connectionId or ~service decorator on credential message.` + ) + } return credentialRecord } @@ -328,25 +461,17 @@ export class CredentialsModule { return this.credentialService.findById(connectionId) } - /** - * Retrieve a credential record by connection id and thread id - * - * @param connectionId The connection id - * @param threadId The thread id - * @throws {RecordNotFoundError} If no record is found - * @throws {RecordDuplicateError} If multiple records are found - * @returns The credential record - */ - public getByConnectionAndThreadId(connectionId: string, threadId: string): Promise { - return this.credentialService.getByConnectionAndThreadId(connectionId, threadId) - } - private registerHandlers(dispatcher: Dispatcher) { dispatcher.registerHandler( new ProposeCredentialHandler(this.credentialService, this.agentConfig, this.credentialResponseCoordinator) ) dispatcher.registerHandler( - new OfferCredentialHandler(this.credentialService, this.agentConfig, this.credentialResponseCoordinator) + new OfferCredentialHandler( + this.credentialService, + this.agentConfig, + this.credentialResponseCoordinator, + this.mediationRecipientService + ) ) dispatcher.registerHandler( new RequestCredentialHandler(this.credentialService, this.agentConfig, this.credentialResponseCoordinator) diff --git a/packages/core/src/modules/credentials/__tests__/CredentialService.test.ts b/packages/core/src/modules/credentials/__tests__/CredentialService.test.ts index 39e8dfad09..b401fe8a12 100644 --- a/packages/core/src/modules/credentials/__tests__/CredentialService.test.ts +++ b/packages/core/src/modules/credentials/__tests__/CredentialService.test.ts @@ -152,7 +152,10 @@ describe('CredentialService', () => { credentialService = new CredentialService( credentialRepository, - { getById: () => Promise.resolve(connection) } as unknown as ConnectionService, + { + getById: () => Promise.resolve(connection), + assertConnectionOrServiceDecorator: () => true, + } as unknown as ConnectionService, ledgerService, agentConfig, indyIssuerService, @@ -179,7 +182,7 @@ describe('CredentialService', () => { const repositorySaveSpy = jest.spyOn(credentialRepository, 'save') // when - const { message: credentialOffer } = await credentialService.createOffer(connection, credentialTemplate) + const { message: credentialOffer } = await credentialService.createOffer(credentialTemplate, connection) // then expect(repositorySaveSpy).toHaveBeenCalledTimes(1) @@ -199,7 +202,7 @@ describe('CredentialService', () => { const eventListenerMock = jest.fn() eventEmitter.on(CredentialEventTypes.CredentialStateChanged, eventListenerMock) - await credentialService.createOffer(connection, credentialTemplate) + await credentialService.createOffer(credentialTemplate, connection) expect(eventListenerMock).toHaveBeenCalledWith({ type: 'CredentialStateChanged', @@ -213,7 +216,7 @@ describe('CredentialService', () => { }) test('returns credential offer message', async () => { - const { message: credentialOffer } = await credentialService.createOffer(connection, credentialTemplate) + const { message: credentialOffer } = await credentialService.createOffer(credentialTemplate, connection) expect(credentialOffer.toJSON()).toMatchObject({ '@id': expect.any(String), @@ -320,7 +323,9 @@ describe('CredentialService', () => { const repositoryUpdateSpy = jest.spyOn(credentialRepository, 'update') // when - await credentialService.createRequest(credentialRecord) + await credentialService.createRequest(credentialRecord, { + holderDid: connection.did, + }) // then expect(repositoryUpdateSpy).toHaveBeenCalledTimes(1) @@ -336,7 +341,9 @@ describe('CredentialService', () => { eventEmitter.on(CredentialEventTypes.CredentialStateChanged, eventListenerMock) // when - await credentialService.createRequest(credentialRecord) + await credentialService.createRequest(credentialRecord, { + holderDid: connection.did, + }) // then expect(eventListenerMock).toHaveBeenCalledWith({ @@ -357,6 +364,7 @@ describe('CredentialService', () => { // when const { message: credentialRequest } = await credentialService.createRequest(credentialRecord, { comment, + holderDid: connection.did, }) // then @@ -384,9 +392,9 @@ describe('CredentialService', () => { test(`throws an error when state transition is invalid`, async () => { await Promise.all( invalidCredentialStates.map(async (state) => { - await expect(credentialService.createRequest(mockCredentialRecord({ state }))).rejects.toThrowError( - `Credential record is in invalid state ${state}. Valid states are: ${validState}.` - ) + await expect( + credentialService.createRequest(mockCredentialRecord({ state }), { holderDid: connection.id }) + ).rejects.toThrowError(`Credential record is in invalid state ${state}. Valid states are: ${validState}.`) }) ) }) @@ -919,7 +927,7 @@ describe('CredentialService', () => { it('getById should return value from credentialRepository.getSingleByQuery', async () => { const expected = mockCredentialRecord() mockFunction(credentialRepository.getSingleByQuery).mockReturnValue(Promise.resolve(expected)) - const result = await credentialService.getByConnectionAndThreadId('connectionId', 'threadId') + const result = await credentialService.getByThreadAndConnectionId('threadId', 'connectionId') expect(credentialRepository.getSingleByQuery).toBeCalledWith({ threadId: 'threadId', connectionId: 'connectionId', diff --git a/packages/core/src/modules/credentials/__tests__/StubWallet.ts b/packages/core/src/modules/credentials/__tests__/StubWallet.ts index f3b5665c86..b7cf8dd491 100644 --- a/packages/core/src/modules/credentials/__tests__/StubWallet.ts +++ b/packages/core/src/modules/credentials/__tests__/StubWallet.ts @@ -1,6 +1,6 @@ /* eslint-disable @typescript-eslint/no-unused-vars */ -import type { PackedMessage, UnpackedMessageContext } from '../../../types' +import type { UnpackedMessageContext, WireMessage } from '../../../types' import type { Buffer } from '../../../utils/buffer' import type { Wallet } from '../../../wallet/Wallet' import type { @@ -45,10 +45,10 @@ export class StubWallet implements Wallet { payload: Record, recipientKeys: string[], senderVk: string | null - ): Promise { + ): Promise { throw new Error('Method not implemented.') } - public unpack(messagePackage: PackedMessage): Promise { + public unpack(messagePackage: WireMessage): Promise { throw new Error('Method not implemented.') } public sign(data: Buffer, verkey: string): Promise { diff --git a/packages/core/src/modules/credentials/handlers/IssueCredentialHandler.ts b/packages/core/src/modules/credentials/handlers/IssueCredentialHandler.ts index 972e5ec337..293475f96e 100644 --- a/packages/core/src/modules/credentials/handlers/IssueCredentialHandler.ts +++ b/packages/core/src/modules/credentials/handlers/IssueCredentialHandler.ts @@ -4,7 +4,7 @@ import type { CredentialResponseCoordinator } from '../CredentialResponseCoordin import type { CredentialRecord } from '../repository/CredentialRecord' import type { CredentialService } from '../services' -import { createOutboundMessage } from '../../../agent/helpers' +import { createOutboundMessage, createOutboundServiceMessage } from '../../../agent/helpers' import { IssueCredentialMessage } from '../messages' export class IssueCredentialHandler implements Handler { @@ -30,20 +30,25 @@ export class IssueCredentialHandler implements Handler { } } - private async createAck( - credentialRecord: CredentialRecord, - messageContext: HandlerInboundMessage - ) { + private async createAck(record: CredentialRecord, messageContext: HandlerInboundMessage) { this.agentConfig.logger.info( `Automatically sending acknowledgement with autoAccept on ${this.agentConfig.autoAcceptCredentials}` ) + const { message, credentialRecord } = await this.credentialService.createAck(record) + + if (messageContext.connection) { + return createOutboundMessage(messageContext.connection, message) + } else if (credentialRecord.credentialMessage?.service && credentialRecord.requestMessage?.service) { + const recipientService = credentialRecord.credentialMessage.service + const ourService = credentialRecord.requestMessage.service - if (!messageContext.connection) { - this.agentConfig.logger.error(`No connection on the messageContext`) - return + return createOutboundServiceMessage({ + payload: message, + service: recipientService.toDidCommService(), + senderKey: ourService.recipientKeys[0], + }) } - const { message } = await this.credentialService.createAck(credentialRecord) - return createOutboundMessage(messageContext.connection, message) + this.agentConfig.logger.error(`Could not automatically create credential ack`) } } diff --git a/packages/core/src/modules/credentials/handlers/OfferCredentialHandler.ts b/packages/core/src/modules/credentials/handlers/OfferCredentialHandler.ts index 6413a87f57..d02e6d74a4 100644 --- a/packages/core/src/modules/credentials/handlers/OfferCredentialHandler.ts +++ b/packages/core/src/modules/credentials/handlers/OfferCredentialHandler.ts @@ -1,51 +1,77 @@ import type { AgentConfig } from '../../../agent/AgentConfig' import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' +import type { MediationRecipientService } from '../../routing/services/MediationRecipientService' import type { CredentialResponseCoordinator } from '../CredentialResponseCoordinator' import type { CredentialRecord } from '../repository/CredentialRecord' import type { CredentialService } from '../services' -import { createOutboundMessage } from '../../../agent/helpers' +import { createOutboundMessage, createOutboundServiceMessage } from '../../../agent/helpers' +import { ServiceDecorator } from '../../../decorators/service/ServiceDecorator' import { OfferCredentialMessage } from '../messages' export class OfferCredentialHandler implements Handler { private credentialService: CredentialService private agentConfig: AgentConfig - private credentialReponseCoordinator: CredentialResponseCoordinator + private credentialResponseCoordinator: CredentialResponseCoordinator + private mediationRecipientService: MediationRecipientService public supportedMessages = [OfferCredentialMessage] public constructor( credentialService: CredentialService, agentConfig: AgentConfig, - credentialResponseCoordinator: CredentialResponseCoordinator + credentialResponseCoordinator: CredentialResponseCoordinator, + mediationRecipientService: MediationRecipientService ) { this.credentialService = credentialService this.agentConfig = agentConfig - this.credentialReponseCoordinator = credentialResponseCoordinator + this.credentialResponseCoordinator = credentialResponseCoordinator + this.mediationRecipientService = mediationRecipientService } public async handle(messageContext: HandlerInboundMessage) { const credentialRecord = await this.credentialService.processOffer(messageContext) - if (this.credentialReponseCoordinator.shouldAutoRespondToOffer(credentialRecord)) { + if (this.credentialResponseCoordinator.shouldAutoRespondToOffer(credentialRecord)) { return await this.createRequest(credentialRecord, messageContext) } } - private async createRequest( - credentialRecord: CredentialRecord, - messageContext: HandlerInboundMessage - ) { + private async createRequest(record: CredentialRecord, messageContext: HandlerInboundMessage) { this.agentConfig.logger.info( `Automatically sending request with autoAccept on ${this.agentConfig.autoAcceptCredentials}` ) - if (!messageContext.connection) { - this.agentConfig.logger.error(`No connection on the messageContext`) - return - } + if (messageContext.connection) { + const { message } = await this.credentialService.createRequest(record, { + holderDid: messageContext.connection.did, + }) + + return createOutboundMessage(messageContext.connection, message) + } else if (record.offerMessage?.service) { + const routing = await this.mediationRecipientService.getRouting() + const ourService = new ServiceDecorator({ + serviceEndpoint: routing.endpoint, + recipientKeys: [routing.verkey], + routingKeys: routing.routingKeys, + }) + const recipientService = record.offerMessage.service - const { message } = await this.credentialService.createRequest(credentialRecord) + const { message, credentialRecord } = await this.credentialService.createRequest(record, { + holderDid: ourService.recipientKeys[0], + }) + + // Set and save ~service decorator to record (to remember our verkey) + message.service = ourService + credentialRecord.requestMessage = message + await this.credentialService.update(credentialRecord) + + return createOutboundServiceMessage({ + payload: message, + service: recipientService.toDidCommService(), + senderKey: ourService.recipientKeys[0], + }) + } - return createOutboundMessage(messageContext.connection, message) + this.agentConfig.logger.error(`Could not automatically create credential request`) } } diff --git a/packages/core/src/modules/credentials/handlers/RequestCredentialHandler.ts b/packages/core/src/modules/credentials/handlers/RequestCredentialHandler.ts index 476ddac778..c4a4d449c4 100644 --- a/packages/core/src/modules/credentials/handlers/RequestCredentialHandler.ts +++ b/packages/core/src/modules/credentials/handlers/RequestCredentialHandler.ts @@ -4,7 +4,7 @@ import type { CredentialResponseCoordinator } from '../CredentialResponseCoordin import type { CredentialRecord } from '../repository/CredentialRecord' import type { CredentialService } from '../services' -import { createOutboundMessage } from '../../../agent/helpers' +import { createOutboundMessage, createOutboundServiceMessage } from '../../../agent/helpers' import { RequestCredentialMessage } from '../messages' export class RequestCredentialHandler implements Handler { @@ -31,20 +31,33 @@ export class RequestCredentialHandler implements Handler { } private async createCredential( - credentialRecord: CredentialRecord, + record: CredentialRecord, messageContext: HandlerInboundMessage ) { this.agentConfig.logger.info( `Automatically sending credential with autoAccept on ${this.agentConfig.autoAcceptCredentials}` ) - if (!messageContext.connection) { - this.agentConfig.logger.error(`No connection on the messageContext`) - return - } + const { message, credentialRecord } = await this.credentialService.createCredential(record) + + if (messageContext.connection) { + return createOutboundMessage(messageContext.connection, message) + } else if (credentialRecord.requestMessage?.service && credentialRecord.offerMessage?.service) { + const recipientService = credentialRecord.requestMessage.service + const ourService = credentialRecord.offerMessage.service - const { message } = await this.credentialService.createCredential(credentialRecord) + // Set ~service, update message in record (for later use) + message.setService(ourService) + credentialRecord.credentialMessage = message + await this.credentialService.update(credentialRecord) + + return createOutboundServiceMessage({ + payload: message, + service: recipientService.toDidCommService(), + senderKey: ourService.recipientKeys[0], + }) + } - return createOutboundMessage(messageContext.connection, message) + this.agentConfig.logger.error(`Could not automatically create credential request`) } } diff --git a/packages/core/src/modules/credentials/repository/CredentialRecord.ts b/packages/core/src/modules/credentials/repository/CredentialRecord.ts index b3e7b0ff67..e5a44845ee 100644 --- a/packages/core/src/modules/credentials/repository/CredentialRecord.ts +++ b/packages/core/src/modules/credentials/repository/CredentialRecord.ts @@ -27,7 +27,7 @@ export interface CredentialRecordProps { id?: string createdAt?: Date state: CredentialState - connectionId: string + connectionId?: string threadId: string credentialId?: string @@ -45,13 +45,13 @@ export interface CredentialRecordProps { export type CustomCredentialTags = TagsBase export type DefaultCredentialTags = { threadId: string - connectionId: string + connectionId?: string state: CredentialState credentialId?: string } export class CredentialRecord extends BaseRecord { - public connectionId!: string + public connectionId?: string public threadId!: string public credentialId?: string public state!: CredentialState @@ -144,7 +144,11 @@ export class CredentialRecord extends BaseRecord({ @@ -265,11 +266,11 @@ export class CredentialService { * */ public async createOffer( - connectionRecord: ConnectionRecord, - credentialTemplate: CredentialOfferTemplate + credentialTemplate: CredentialOfferTemplate, + connectionRecord?: ConnectionRecord ): Promise> { // Assert - connectionRecord.assertReady() + connectionRecord?.assertReady() // Create message const { credentialDefinitionId, comment, preview, linkedAttachments } = credentialTemplate @@ -297,7 +298,7 @@ export class CredentialService { // Create record const credentialRecord = new CredentialRecord({ - connectionId: connectionRecord.id, + connectionId: connectionRecord?.id, threadId: credentialOfferMessage.id, offerMessage: credentialOfferMessage, credentialAttributes: credentialPreview.attributes, @@ -336,16 +337,9 @@ export class CredentialService { let credentialRecord: CredentialRecord const { message: credentialOfferMessage, connection } = messageContext - // Assert connection - connection?.assertReady() - if (!connection) { - throw new AriesFrameworkError( - `No connection associated with incoming credential offer message with thread id ${credentialOfferMessage.threadId}` - ) - } + this.logger.debug(`Processing credential offer with id ${credentialOfferMessage.id}`) const indyCredentialOffer = credentialOfferMessage.indyCredentialOffer - if (!indyCredentialOffer) { throw new AriesFrameworkError( `Missing required base64 encoded attachment data for credential offer with thread id ${credentialOfferMessage.threadId}` @@ -354,10 +348,14 @@ export class CredentialService { try { // Credential record already exists - credentialRecord = await this.getByConnectionAndThreadId(connection.id, credentialOfferMessage.threadId) + credentialRecord = await this.getByThreadAndConnectionId(credentialOfferMessage.threadId, connection?.id) // Assert credentialRecord.assertState(CredentialState.ProposalSent) + this.connectionService.assertConnectionOrServiceDecorator(messageContext, { + previousReceivedMessage: credentialRecord.offerMessage, + previousSentMessage: credentialRecord.proposalMessage, + }) credentialRecord.offerMessage = credentialOfferMessage credentialRecord.linkedAttachments = credentialOfferMessage.attachments?.filter((attachment) => @@ -369,7 +367,7 @@ export class CredentialService { } catch { // No credential record exists with thread id credentialRecord = new CredentialRecord({ - connectionId: connection.id, + connectionId: connection?.id, threadId: credentialOfferMessage.id, offerMessage: credentialOfferMessage, credentialAttributes: credentialOfferMessage.credentialPreview.attributes, @@ -380,6 +378,9 @@ export class CredentialService { state: CredentialState.OfferReceived, }) + // Assert + this.connectionService.assertConnectionOrServiceDecorator(messageContext) + // Save in repository await this.credentialRepository.save(credentialRecord) this.eventEmitter.emit({ @@ -404,14 +405,11 @@ export class CredentialService { */ public async createRequest( credentialRecord: CredentialRecord, - options?: CredentialRequestOptions + options: CredentialRequestOptions ): Promise> { // Assert credential credentialRecord.assertState(CredentialState.OfferReceived) - const connection = await this.connectionService.getById(credentialRecord.connectionId) - const holderDid = connection.did - const credentialOffer = credentialRecord.offerMessage?.indyCredentialOffer if (!credentialOffer) { @@ -423,7 +421,7 @@ export class CredentialService { const credentialDefinition = await this.ledgerService.getCredentialDefinition(credentialOffer.cred_def_id) const [credReq, credReqMetadata] = await this.indyHolderService.createCredentialRequest({ - holderDid, + holderDid: options.holderDid, credentialOffer, credentialDefinition, }) @@ -470,13 +468,7 @@ export class CredentialService { ): Promise { const { message: credentialRequestMessage, connection } = messageContext - // Assert connection - connection?.assertReady() - if (!connection) { - throw new AriesFrameworkError( - `No connection associated with incoming credential request message with thread id ${credentialRequestMessage.threadId}` - ) - } + this.logger.debug(`Processing credential request with id ${credentialRequestMessage.id}`) const indyCredentialRequest = credentialRequestMessage?.indyCredentialRequest @@ -486,8 +478,14 @@ export class CredentialService { ) } - const credentialRecord = await this.getByConnectionAndThreadId(connection.id, credentialRequestMessage.threadId) + const credentialRecord = await this.getByThreadAndConnectionId(credentialRequestMessage.threadId, connection?.id) + + // Assert credentialRecord.assertState(CredentialState.OfferSent) + this.connectionService.assertConnectionOrServiceDecorator(messageContext, { + previousReceivedMessage: credentialRecord.proposalMessage, + previousSentMessage: credentialRecord.offerMessage, + }) this.logger.debug('Credential record found when processing credential request', credentialRecord) @@ -596,17 +594,16 @@ export class CredentialService { ): Promise { const { message: issueCredentialMessage, connection } = messageContext - // Assert connection - connection?.assertReady() - if (!connection) { - throw new AriesFrameworkError( - `No connection associated with incoming presentation message with thread id ${issueCredentialMessage.threadId}` - ) - } + this.logger.debug(`Processing credential with id ${issueCredentialMessage.id}`) + + const credentialRecord = await this.getByThreadAndConnectionId(issueCredentialMessage.threadId, connection?.id) - // Assert credential record - const credentialRecord = await this.getByConnectionAndThreadId(connection.id, issueCredentialMessage.threadId) + // Assert credentialRecord.assertState(CredentialState.RequestSent) + this.connectionService.assertConnectionOrServiceDecorator(messageContext, { + previousReceivedMessage: credentialRecord.offerMessage, + previousSentMessage: credentialRecord.requestMessage, + }) if (!credentialRecord.metadata.requestMetadata) { throw new AriesFrameworkError(`Missing required request metadata for credential with id ${credentialRecord.id}`) @@ -667,17 +664,16 @@ export class CredentialService { public async processAck(messageContext: InboundMessageContext): Promise { const { message: credentialAckMessage, connection } = messageContext - // Assert connection - connection?.assertReady() - if (!connection) { - throw new AriesFrameworkError( - `No connection associated with incoming presentation acknowledgement message with thread id ${credentialAckMessage.threadId}` - ) - } + this.logger.debug(`Processing credential ack with id ${credentialAckMessage.id}`) - // Assert credential record - const credentialRecord = await this.getByConnectionAndThreadId(connection.id, credentialAckMessage.threadId) + const credentialRecord = await this.getByThreadAndConnectionId(credentialAckMessage.threadId, connection?.id) + + // Assert credentialRecord.assertState(CredentialState.CredentialIssued) + this.connectionService.assertConnectionOrServiceDecorator(messageContext, { + previousReceivedMessage: credentialRecord.requestMessage, + previousSentMessage: credentialRecord.credentialMessage, + }) // Update record await this.updateState(credentialRecord, CredentialState.Done) @@ -725,13 +721,17 @@ export class CredentialService { * @throws {RecordDuplicateError} If multiple records are found * @returns The credential record */ - public getByConnectionAndThreadId(connectionId: string, threadId: string): Promise { + public getByThreadAndConnectionId(threadId: string, connectionId?: string): Promise { return this.credentialRepository.getSingleByQuery({ - threadId, connectionId, + threadId, }) } + public update(credentialRecord: CredentialRecord) { + return this.credentialRepository.update(credentialRecord) + } + /** * Update the record to a new state and emit an state changed event. Also updates the record * in storage. @@ -770,6 +770,7 @@ export interface CredentialOfferTemplate { } export interface CredentialRequestOptions { + holderDid: string comment?: string autoAcceptCredential?: AutoAcceptCredential } diff --git a/packages/core/src/modules/proofs/ProofResponseCoordinator.ts b/packages/core/src/modules/proofs/ProofResponseCoordinator.ts index eb0574f1cc..859e5c4ae9 100644 --- a/packages/core/src/modules/proofs/ProofResponseCoordinator.ts +++ b/packages/core/src/modules/proofs/ProofResponseCoordinator.ts @@ -34,7 +34,7 @@ export class ProofResponseCoordinator { /** * Checks whether it should automatically respond to a proposal */ - public shoudlAutoRespondToProposal(proofRecord: ProofRecord) { + public shouldAutoRespondToProposal(proofRecord: ProofRecord) { const autoAccept = ProofResponseCoordinator.composeAutoAccept( proofRecord.autoAcceptProof, this.agentConfig.autoAcceptProofs @@ -66,7 +66,7 @@ export class ProofResponseCoordinator { } /** - * Checks whether it should automatically respond to a presention of proof + * Checks whether it should automatically respond to a presentation of proof */ public shouldAutoRespondToPresentation(proofRecord: ProofRecord) { const autoAccept = ProofResponseCoordinator.composeAutoAccept( diff --git a/packages/core/src/modules/proofs/ProofsModule.ts b/packages/core/src/modules/proofs/ProofsModule.ts index 17ca55f098..41c3b78657 100644 --- a/packages/core/src/modules/proofs/ProofsModule.ts +++ b/packages/core/src/modules/proofs/ProofsModule.ts @@ -1,5 +1,5 @@ import type { AutoAcceptProof } from './ProofAutoAcceptType' -import type { PresentationPreview } from './messages' +import type { PresentationPreview, RequestPresentationMessage } from './messages' import type { RequestedCredentials, RetrievedCredentials } from './models' import type { ProofRecord } from './repository/ProofRecord' @@ -9,8 +9,10 @@ import { AgentConfig } from '../../agent/AgentConfig' import { Dispatcher } from '../../agent/Dispatcher' import { MessageSender } from '../../agent/MessageSender' import { createOutboundMessage } from '../../agent/helpers' +import { ServiceDecorator } from '../../decorators/service/ServiceDecorator' import { AriesFrameworkError } from '../../error' import { ConnectionService } from '../connections/services/ConnectionService' +import { MediationRecipientService } from '../routing/services/MediationRecipientService' import { ProofResponseCoordinator } from './ProofResponseCoordinator' import { @@ -27,6 +29,7 @@ export class ProofsModule { private proofService: ProofService private connectionService: ConnectionService private messageSender: MessageSender + private mediationRecipientService: MediationRecipientService private agentConfig: AgentConfig private proofResponseCoordinator: ProofResponseCoordinator @@ -34,6 +37,7 @@ export class ProofsModule { dispatcher: Dispatcher, proofService: ProofService, connectionService: ConnectionService, + mediationRecipientService: MediationRecipientService, agentConfig: AgentConfig, messageSender: MessageSender, proofResponseCoordinator: ProofResponseCoordinator @@ -41,6 +45,7 @@ export class ProofsModule { this.proofService = proofService this.connectionService = connectionService this.messageSender = messageSender + this.mediationRecipientService = mediationRecipientService this.agentConfig = agentConfig this.proofResponseCoordinator = proofResponseCoordinator this.registerHandlers(dispatcher) @@ -95,6 +100,13 @@ export class ProofsModule { } ): Promise { const proofRecord = await this.proofService.getById(proofRecordId) + + if (!proofRecord.connectionId) { + throw new AriesFrameworkError( + `No connectionId found for credential record '${proofRecord.id}'. Connection-less issuance does not support presentation proposal or negotiation.` + ) + } + const connection = await this.connectionService.getById(proofRecord.connectionId) const presentationProposal = proofRecord.proposalMessage?.presentationProposal @@ -124,17 +136,13 @@ export class ProofsModule { * * @param connectionId The connection to send the proof request to * @param proofRequestOptions Options to build the proof request - * @param config Additional configuration to use for the request * @returns Proof record associated with the sent request message * */ public async requestProof( connectionId: string, - proofRequestOptions: Partial>, - config?: { - comment?: string - autoAcceptProof?: AutoAcceptProof - } + proofRequestOptions: ProofRequestOptions, + config?: ProofRequestConfig ): Promise { const connection = await this.connectionService.getById(connectionId) @@ -148,7 +156,7 @@ export class ProofsModule { requestedPredicates: proofRequestOptions.requestedPredicates, }) - const { message, proofRecord } = await this.proofService.createRequest(connection, proofRequest, config) + const { message, proofRecord } = await this.proofService.createRequest(proofRequest, connection, config) const outboundMessage = createOutboundMessage(connection, message) await this.messageSender.sendMessage(outboundMessage) @@ -156,6 +164,48 @@ export class ProofsModule { return proofRecord } + /** + * Initiate a new presentation exchange as verifier by creating a presentation request + * not bound to any connection. The request must be delivered out-of-band to the holder + * + * @param proofRequestOptions Options to build the proof request + * @returns The proof record and proof request message + * + */ + public async createOutOfBandRequest( + proofRequestOptions: ProofRequestOptions, + config?: ProofRequestConfig + ): Promise<{ + requestMessage: RequestPresentationMessage + proofRecord: ProofRecord + }> { + const nonce = proofRequestOptions.nonce ?? (await this.proofService.generateProofRequestNonce()) + + const proofRequest = new ProofRequest({ + name: proofRequestOptions.name ?? 'proof-request', + version: proofRequestOptions.name ?? '1.0', + nonce, + requestedAttributes: proofRequestOptions.requestedAttributes, + requestedPredicates: proofRequestOptions.requestedPredicates, + }) + + const { message, proofRecord } = await this.proofService.createRequest(proofRequest, undefined, config) + + // Create and set ~service decorator + const routing = await this.mediationRecipientService.getRouting() + message.service = new ServiceDecorator({ + serviceEndpoint: routing.endpoint, + recipientKeys: [routing.verkey], + routingKeys: routing.routingKeys, + }) + + // Save ~service decorator to record (to remember our verkey) + proofRecord.requestMessage = message + await this.proofService.update(proofRecord) + + return { proofRecord, requestMessage: message } + } + /** * Accept a presentation request as prover (by sending a presentation message) to the connection * associated with the proof record. @@ -173,15 +223,50 @@ export class ProofsModule { comment?: string } ): Promise { - const proofRecord = await this.proofService.getById(proofRecordId) - const connection = await this.connectionService.getById(proofRecord.connectionId) + const record = await this.proofService.getById(proofRecordId) + const { message, proofRecord } = await this.proofService.createPresentation(record, requestedCredentials, config) - const { message } = await this.proofService.createPresentation(proofRecord, requestedCredentials, config) + // Use connection if present + if (proofRecord.connectionId) { + const connection = await this.connectionService.getById(proofRecord.connectionId) - const outboundMessage = createOutboundMessage(connection, message) - await this.messageSender.sendMessage(outboundMessage) + const outboundMessage = createOutboundMessage(connection, message) + await this.messageSender.sendMessage(outboundMessage) - return proofRecord + return proofRecord + } + // Use ~service decorator otherwise + else if (proofRecord.requestMessage?.service) { + // Create ~service decorator + const routing = await this.mediationRecipientService.getRouting() + const ourService = new ServiceDecorator({ + serviceEndpoint: routing.endpoint, + recipientKeys: [routing.verkey], + routingKeys: routing.routingKeys, + }) + + const recipientService = proofRecord.requestMessage.service + + // Set and save ~service decorator to record (to remember our verkey) + message.service = ourService + proofRecord.presentationMessage = message + await this.proofService.update(proofRecord) + + await this.messageSender.sendMessageToService({ + message, + service: recipientService.toDidCommService(), + senderKey: ourService.recipientKeys[0], + returnRoute: true, + }) + + return proofRecord + } + // Cannot send message without connectionId or ~service decorator + else { + throw new AriesFrameworkError( + `Cannot accept presentation request without connectionId or ~service decorator on presentation request.` + ) + } } /** @@ -193,12 +278,34 @@ export class ProofsModule { * */ public async acceptPresentation(proofRecordId: string): Promise { - const proofRecord = await this.proofService.getById(proofRecordId) - const connection = await this.connectionService.getById(proofRecord.connectionId) + const record = await this.proofService.getById(proofRecordId) + const { message, proofRecord } = await this.proofService.createAck(record) + + // Use connection if present + if (proofRecord.connectionId) { + const connection = await this.connectionService.getById(proofRecord.connectionId) + const outboundMessage = createOutboundMessage(connection, message) + await this.messageSender.sendMessage(outboundMessage) + } + // Use ~service decorator otherwise + else if (proofRecord.requestMessage?.service && proofRecord.presentationMessage?.service) { + const recipientService = proofRecord.presentationMessage?.service + const ourService = proofRecord.requestMessage?.service + + await this.messageSender.sendMessageToService({ + message, + service: recipientService.toDidCommService(), + senderKey: ourService.recipientKeys[0], + returnRoute: true, + }) + } - const { message } = await this.proofService.createAck(proofRecord) - const outboundMessage = createOutboundMessage(connection, message) - await this.messageSender.sendMessage(outboundMessage) + // Cannot send message without credentialId or ~service decorator + else { + throw new AriesFrameworkError( + `Cannot accept presentation without connectionId or ~service decorator on presentation message.` + ) + } return proofRecord } @@ -239,7 +346,7 @@ export class ProofsModule { * * @returns List containing all proof records */ - public async getAll(): Promise { + public getAll(): Promise { return this.proofService.getAll() } @@ -267,25 +374,17 @@ export class ProofsModule { return this.proofService.findById(proofRecordId) } - /** - * Retrieve a proof record by connection id and thread id - * - * @param connectionId The connection id - * @param threadId The thread id - * @throws {RecordNotFoundError} If no record is found - * @throws {RecordDuplicateError} If multiple records are found - * @returns The proof record - */ - public async getByConnectionAndThreadId(connectionId: string, threadId: string): Promise { - return this.proofService.getByConnectionAndThreadId(connectionId, threadId) - } - private registerHandlers(dispatcher: Dispatcher) { dispatcher.registerHandler( new ProposePresentationHandler(this.proofService, this.agentConfig, this.proofResponseCoordinator) ) dispatcher.registerHandler( - new RequestPresentationHandler(this.proofService, this.agentConfig, this.proofResponseCoordinator) + new RequestPresentationHandler( + this.proofService, + this.agentConfig, + this.proofResponseCoordinator, + this.mediationRecipientService + ) ) dispatcher.registerHandler( new PresentationHandler(this.proofService, this.agentConfig, this.proofResponseCoordinator) @@ -293,3 +392,12 @@ export class ProofsModule { dispatcher.registerHandler(new PresentationAckHandler(this.proofService)) } } + +export type ProofRequestOptions = Partial< + Pick +> + +export interface ProofRequestConfig { + comment?: string + autoAcceptProof?: AutoAcceptProof +} diff --git a/packages/core/src/modules/proofs/handlers/PresentationHandler.ts b/packages/core/src/modules/proofs/handlers/PresentationHandler.ts index ca4714186b..660254080e 100644 --- a/packages/core/src/modules/proofs/handlers/PresentationHandler.ts +++ b/packages/core/src/modules/proofs/handlers/PresentationHandler.ts @@ -4,7 +4,7 @@ import type { ProofResponseCoordinator } from '../ProofResponseCoordinator' import type { ProofRecord } from '../repository' import type { ProofService } from '../services' -import { createOutboundMessage } from '../../../agent/helpers' +import { createOutboundMessage, createOutboundServiceMessage } from '../../../agent/helpers' import { PresentationMessage } from '../messages' export class PresentationHandler implements Handler { @@ -31,18 +31,26 @@ export class PresentationHandler implements Handler { } } - private async createAck(proofRecord: ProofRecord, messageContext: HandlerInboundMessage) { + private async createAck(record: ProofRecord, messageContext: HandlerInboundMessage) { this.agentConfig.logger.info( `Automatically sending acknowledgement with autoAccept on ${this.agentConfig.autoAcceptProofs}` ) - if (!messageContext.connection) { - this.agentConfig.logger.error('No connection on the messageContext') - return - } + const { message, proofRecord } = await this.proofService.createAck(record) + + if (messageContext.connection) { + return createOutboundMessage(messageContext.connection, message) + } else if (proofRecord.requestMessage?.service && proofRecord.presentationMessage?.service) { + const recipientService = proofRecord.presentationMessage?.service + const ourService = proofRecord.requestMessage?.service - const { message } = await this.proofService.createAck(proofRecord) + return createOutboundServiceMessage({ + payload: message, + service: recipientService.toDidCommService(), + senderKey: ourService.recipientKeys[0], + }) + } - return createOutboundMessage(messageContext.connection, message) + this.agentConfig.logger.error(`Could not automatically create presentation ack`) } } diff --git a/packages/core/src/modules/proofs/handlers/ProposePresentationHandler.ts b/packages/core/src/modules/proofs/handlers/ProposePresentationHandler.ts index 387431bc7f..de29cc2e1d 100644 --- a/packages/core/src/modules/proofs/handlers/ProposePresentationHandler.ts +++ b/packages/core/src/modules/proofs/handlers/ProposePresentationHandler.ts @@ -26,7 +26,7 @@ export class ProposePresentationHandler implements Handler { public async handle(messageContext: HandlerInboundMessage) { const proofRecord = await this.proofService.processProposal(messageContext) - if (this.proofResponseCoordinator.shoudlAutoRespondToProposal(proofRecord)) { + if (this.proofResponseCoordinator.shouldAutoRespondToProposal(proofRecord)) { return await this.createRequest(proofRecord, messageContext) } } diff --git a/packages/core/src/modules/proofs/handlers/RequestPresentationHandler.ts b/packages/core/src/modules/proofs/handlers/RequestPresentationHandler.ts index 8c14c0ffe9..68c32cc473 100644 --- a/packages/core/src/modules/proofs/handlers/RequestPresentationHandler.ts +++ b/packages/core/src/modules/proofs/handlers/RequestPresentationHandler.ts @@ -1,26 +1,31 @@ import type { AgentConfig } from '../../../agent/AgentConfig' import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' +import type { MediationRecipientService } from '../../routing' import type { ProofResponseCoordinator } from '../ProofResponseCoordinator' import type { ProofRecord } from '../repository' import type { ProofService } from '../services' -import { createOutboundMessage } from '../../../agent/helpers' +import { createOutboundMessage, createOutboundServiceMessage } from '../../../agent/helpers' +import { ServiceDecorator } from '../../../decorators/service/ServiceDecorator' import { RequestPresentationMessage } from '../messages' export class RequestPresentationHandler implements Handler { private proofService: ProofService private agentConfig: AgentConfig private proofResponseCoordinator: ProofResponseCoordinator + private mediationRecipientService: MediationRecipientService public supportedMessages = [RequestPresentationMessage] public constructor( proofService: ProofService, agentConfig: AgentConfig, - proofResponseCoordinator: ProofResponseCoordinator + proofResponseCoordinator: ProofResponseCoordinator, + mediationRecipientService: MediationRecipientService ) { this.proofService = proofService this.agentConfig = agentConfig this.proofResponseCoordinator = proofResponseCoordinator + this.mediationRecipientService = mediationRecipientService } public async handle(messageContext: HandlerInboundMessage) { @@ -32,33 +37,53 @@ export class RequestPresentationHandler implements Handler { } private async createPresentation( - proofRecord: ProofRecord, + record: ProofRecord, messageContext: HandlerInboundMessage ) { - const indyProofRequest = proofRecord.requestMessage?.indyProofRequest + const indyProofRequest = record.requestMessage?.indyProofRequest this.agentConfig.logger.info( `Automatically sending presentation with autoAccept on ${this.agentConfig.autoAcceptProofs}` ) - if (!messageContext.connection) { - this.agentConfig.logger.error('No connection on the messageContext') - return - } - if (!indyProofRequest) { return } const retrievedCredentials = await this.proofService.getRequestedCredentialsForProofRequest( indyProofRequest, - proofRecord.proposalMessage?.presentationProposal + record.proposalMessage?.presentationProposal ) const requestedCredentials = this.proofService.autoSelectCredentialsForProofRequest(retrievedCredentials) - const { message } = await this.proofService.createPresentation(proofRecord, requestedCredentials) + const { message, proofRecord } = await this.proofService.createPresentation(record, requestedCredentials) + + if (messageContext.connection) { + return createOutboundMessage(messageContext.connection, message) + } else if (proofRecord.requestMessage?.service) { + // Create ~service decorator + const routing = await this.mediationRecipientService.getRouting() + const ourService = new ServiceDecorator({ + serviceEndpoint: routing.endpoint, + recipientKeys: [routing.verkey], + routingKeys: routing.routingKeys, + }) + + const recipientService = proofRecord.requestMessage.service + + // Set and save ~service decorator to record (to remember our verkey) + message.service = ourService + proofRecord.presentationMessage = message + await this.proofService.update(proofRecord) + + return createOutboundServiceMessage({ + payload: message, + service: recipientService.toDidCommService(), + senderKey: ourService.recipientKeys[0], + }) + } - return createOutboundMessage(messageContext.connection, message) + this.agentConfig.logger.error(`Could not automatically create presentation`) } } diff --git a/packages/core/src/modules/proofs/repository/ProofRecord.ts b/packages/core/src/modules/proofs/repository/ProofRecord.ts index 0312bc3ecf..2a9cc2b2f8 100644 --- a/packages/core/src/modules/proofs/repository/ProofRecord.ts +++ b/packages/core/src/modules/proofs/repository/ProofRecord.ts @@ -15,7 +15,7 @@ export interface ProofRecordProps { isVerified?: boolean state: ProofState - connectionId: string + connectionId?: string threadId: string presentationId?: string tags?: CustomProofTags @@ -30,12 +30,12 @@ export interface ProofRecordProps { export type CustomProofTags = TagsBase export type DefaultProofTags = { threadId: string - connectionId: string + connectionId?: string state: ProofState } export class ProofRecord extends BaseRecord { - public connectionId!: string + public connectionId?: string public threadId!: string public isVerified?: boolean public presentationId?: string @@ -94,7 +94,11 @@ export class ProofRecord extends BaseRecord { } public assertConnection(currentConnectionId: string) { - if (this.connectionId !== currentConnectionId) { + if (!this.connectionId) { + throw new AriesFrameworkError( + `Proof record is not associated with any connection. This is often the case with connection-less presentation exchange` + ) + } else if (this.connectionId !== currentConnectionId) { throw new AriesFrameworkError( `Proof record is associated with connection '${this.connectionId}'. Current connection is '${currentConnectionId}'` ) diff --git a/packages/core/src/modules/proofs/services/ProofService.ts b/packages/core/src/modules/proofs/services/ProofService.ts index 141cd82fe7..fa5afaee1b 100644 --- a/packages/core/src/modules/proofs/services/ProofService.ts +++ b/packages/core/src/modules/proofs/services/ProofService.ts @@ -20,7 +20,8 @@ import { JsonTransformer } from '../../../utils/JsonTransformer' import { uuid } from '../../../utils/uuid' import { Wallet } from '../../../wallet/Wallet' import { AckStatus } from '../../common' -import { Credential, CredentialRepository, CredentialUtils } from '../../credentials' +import { ConnectionService } from '../../connections' +import { CredentialUtils, Credential, CredentialRepository } from '../../credentials' import { IndyHolderService, IndyVerifierService } from '../../indy' import { LedgerService } from '../../ledger/services/LedgerService' import { ProofEventTypes } from '../ProofEvents' @@ -61,6 +62,7 @@ export class ProofService { private logger: Logger private indyHolderService: IndyHolderService private indyVerifierService: IndyVerifierService + private connectionService: ConnectionService private eventEmitter: EventEmitter public constructor( @@ -70,6 +72,7 @@ export class ProofService { agentConfig: AgentConfig, indyHolderService: IndyHolderService, indyVerifierService: IndyVerifierService, + connectionService: ConnectionService, eventEmitter: EventEmitter, credentialRepository: CredentialRepository ) { @@ -80,12 +83,13 @@ export class ProofService { this.logger = agentConfig.logger this.indyHolderService = indyHolderService this.indyVerifierService = indyVerifierService + this.connectionService = connectionService this.eventEmitter = eventEmitter } /** * Create a {@link ProposePresentationMessage} not bound to an existing presentation exchange. - * To create a proposal as response to an existing presentation exchange, use {@link ProofService#createProposalAsResponse}. + * To create a proposal as response to an existing presentation exchange, use {@link ProofService.createProposalAsResponse}. * * @param connectionRecord The connection for which to create the presentation proposal * @param presentationProposal The presentation proposal to include in the message @@ -129,7 +133,7 @@ export class ProofService { /** * Create a {@link ProposePresentationMessage} as response to a received presentation request. - * To create a proposal not bound to an existing presentation exchange, use {@link ProofService#createProposal}. + * To create a proposal not bound to an existing presentation exchange, use {@link ProofService.createProposal}. * * @param proofRecord The proof record for which to create the presentation proposal * @param presentationProposal The presentation proposal to include in the message @@ -164,7 +168,7 @@ export class ProofService { /** * Process a received {@link ProposePresentationMessage}. This will not accept the presentation proposal * or send a presentation request. It will only create a new, or update the existing proof record with - * the information from the presentation proposal message. Use {@link ProofService#createRequestAsResponse} + * the information from the presentation proposal message. Use {@link ProofService.createRequestAsResponse} * after calling this method to create a presentation request. * * @param messageContext The message context containing a presentation proposal message @@ -177,20 +181,18 @@ export class ProofService { let proofRecord: ProofRecord const { message: proposalMessage, connection } = messageContext - // Assert connection - connection?.assertReady() - if (!connection) { - throw new AriesFrameworkError( - `No connection associated with incoming presentation proposal message with thread id ${proposalMessage.threadId}` - ) - } + this.logger.debug(`Processing presentation proposal with id ${proposalMessage.id}`) try { // Proof record already exists - proofRecord = await this.getByConnectionAndThreadId(connection.id, proposalMessage.threadId) + proofRecord = await this.getByThreadAndConnectionId(proposalMessage.threadId, connection?.id) // Assert proofRecord.assertState(ProofState.RequestSent) + this.connectionService.assertConnectionOrServiceDecorator(messageContext, { + previousReceivedMessage: proofRecord.proposalMessage, + previousSentMessage: proofRecord.requestMessage, + }) // Update record proofRecord.proposalMessage = proposalMessage @@ -198,12 +200,15 @@ export class ProofService { } catch { // No proof record exists with thread id proofRecord = new ProofRecord({ - connectionId: connection.id, + connectionId: connection?.id, threadId: proposalMessage.threadId, proposalMessage, state: ProofState.ProposalReceived, }) + // Assert + this.connectionService.assertConnectionOrServiceDecorator(messageContext) + // Save record await this.proofRepository.save(proofRecord) this.eventEmitter.emit({ @@ -220,7 +225,7 @@ export class ProofService { /** * Create a {@link RequestPresentationMessage} as response to a received presentation proposal. - * To create a request not bound to an existing presentation exchange, use {@link ProofService#createRequest}. + * To create a request not bound to an existing presentation exchange, use {@link ProofService.createRequest}. * * @param proofRecord The proof record for which to create the presentation request * @param proofRequest The proof request to include in the message @@ -265,22 +270,23 @@ export class ProofService { * Create a {@link RequestPresentationMessage} not bound to an existing presentation exchange. * To create a request as response to an existing presentation exchange, use {@link ProofService#createRequestAsResponse}. * + * @param proofRequestTemplate The proof request template * @param connectionRecord The connection for which to create the presentation request - * @param proofRequest The proof request to include in the message - * @param config Additional configuration to use for the request * @returns Object containing request message and associated proof record * */ public async createRequest( - connectionRecord: ConnectionRecord, proofRequest: ProofRequest, + connectionRecord?: ConnectionRecord, config?: { comment?: string autoAcceptProof?: AutoAcceptProof } ): Promise> { + this.logger.debug(`Creating proof request`) + // Assert - connectionRecord.assertReady() + connectionRecord?.assertReady() // Create message const attachment = new Attachment({ @@ -297,7 +303,7 @@ export class ProofService { // Create record const proofRecord = new ProofRecord({ - connectionId: connectionRecord.id, + connectionId: connectionRecord?.id, threadId: requestPresentationMessage.threadId, requestMessage: requestPresentationMessage, state: ProofState.RequestSent, @@ -316,7 +322,7 @@ export class ProofService { /** * Process a received {@link RequestPresentationMessage}. This will not accept the presentation request * or send a presentation. It will only create a new, or update the existing proof record with - * the information from the presentation request message. Use {@link ProofService#createPresentation} + * the information from the presentation request message. Use {@link ProofService.createPresentation} * after calling this method to create a presentation. * * @param messageContext The message context containing a presentation request message @@ -327,13 +333,7 @@ export class ProofService { let proofRecord: ProofRecord const { message: proofRequestMessage, connection } = messageContext - // Assert connection - connection?.assertReady() - if (!connection) { - throw new AriesFrameworkError( - `No connection associated with incoming presentation request message with thread id ${proofRequestMessage.threadId}` - ) - } + this.logger.debug(`Processing presentation request with id ${proofRequestMessage.id}`) const proofRequest = proofRequestMessage.indyProofRequest @@ -349,10 +349,14 @@ export class ProofService { try { // Proof record already exists - proofRecord = await this.getByConnectionAndThreadId(connection.id, proofRequestMessage.threadId) + proofRecord = await this.getByThreadAndConnectionId(proofRequestMessage.threadId, connection?.id) // Assert proofRecord.assertState(ProofState.ProposalSent) + this.connectionService.assertConnectionOrServiceDecorator(messageContext, { + previousReceivedMessage: proofRecord.requestMessage, + previousSentMessage: proofRecord.proposalMessage, + }) // Update record proofRecord.requestMessage = proofRequestMessage @@ -360,12 +364,15 @@ export class ProofService { } catch { // No proof record exists with thread id proofRecord = new ProofRecord({ - connectionId: connection.id, + connectionId: connection?.id, threadId: proofRequestMessage.threadId, requestMessage: proofRequestMessage, state: ProofState.RequestReceived, }) + // Assert + this.connectionService.assertConnectionOrServiceDecorator(messageContext) + // Save in repository await this.proofRepository.save(proofRecord) this.eventEmitter.emit({ @@ -393,6 +400,8 @@ export class ProofService { comment?: string } ): Promise> { + this.logger.debug(`Creating presentation for proof record with id ${proofRecord.id}`) + // Assert proofRecord.assertState(ProofState.RequestReceived) @@ -438,7 +447,7 @@ export class ProofService { /** * Process a received {@link PresentationMessage}. This will not accept the presentation * or send a presentation acknowledgement. It will only update the existing proof record with - * the information from the presentation message. Use {@link ProofService#createAck} + * the information from the presentation message. Use {@link ProofService.createAck} * after calling this method to create a presentation acknowledgement. * * @param messageContext The message context containing a presentation message @@ -448,17 +457,16 @@ export class ProofService { public async processPresentation(messageContext: InboundMessageContext): Promise { const { message: presentationMessage, connection } = messageContext - // Assert connection - connection?.assertReady() - if (!connection) { - throw new AriesFrameworkError( - `No connection associated with incoming presentation message with thread id ${presentationMessage.threadId}` - ) - } + this.logger.debug(`Processing presentation with id ${presentationMessage.id}`) + + const proofRecord = await this.getByThreadAndConnectionId(presentationMessage.threadId, connection?.id) - // Assert proof record - const proofRecord = await this.getByConnectionAndThreadId(connection.id, presentationMessage.threadId) + // Assert proofRecord.assertState(ProofState.RequestSent) + this.connectionService.assertConnectionOrServiceDecorator(messageContext, { + previousReceivedMessage: proofRecord.proposalMessage, + previousSentMessage: proofRecord.requestMessage, + }) // TODO: add proof class with validator const indyProofJson = presentationMessage.indyProof @@ -494,6 +502,8 @@ export class ProofService { * */ public async createAck(proofRecord: ProofRecord): Promise> { + this.logger.debug(`Creating presentation ack for proof record with id ${proofRecord.id}`) + // Assert proofRecord.assertState(ProofState.PresentationReceived) @@ -519,17 +529,16 @@ export class ProofService { public async processAck(messageContext: InboundMessageContext): Promise { const { message: presentationAckMessage, connection } = messageContext - // Assert connection - connection?.assertReady() - if (!connection) { - throw new AriesFrameworkError( - `No connection associated with incoming presentation acknowledgement message with thread id ${presentationAckMessage.threadId}` - ) - } + this.logger.debug(`Processing presentation ack with id ${presentationAckMessage.id}`) + + const proofRecord = await this.getByThreadAndConnectionId(presentationAckMessage.threadId, connection?.id) - // Assert proof record - const proofRecord = await this.getByConnectionAndThreadId(connection.id, presentationAckMessage.threadId) + // Assert proofRecord.assertState(ProofState.PresentationSent) + this.connectionService.assertConnectionOrServiceDecorator(messageContext, { + previousReceivedMessage: proofRecord.requestMessage, + previousSentMessage: proofRecord.presentationMessage, + }) // Update record await this.updateState(proofRecord, ProofState.Done) @@ -543,7 +552,7 @@ export class ProofService { /** * Create a {@link ProofRequest} from a presentation proposal. This method can be used to create the - * proof request from a received proposal for use in {@link ProofService#createRequestAsResponse} + * proof request from a received proposal for use in {@link ProofService.createRequestAsResponse} * * @param presentationProposal The presentation proposal to create a proof request from * @param config Additional configuration to use for the proof request @@ -628,7 +637,7 @@ export class ProofService { } /** - * Retreives the linked attachments for an {@link indyProofRequest} + * Retrieves the linked attachments for an {@link indyProofRequest} * @param indyProofRequest The proof request for which the linked attachments have to be found * @param requestedCredentials The requested credentials * @returns a list of attachments that are linked to the requested credentials @@ -867,10 +876,14 @@ export class ProofService { * @throws {RecordDuplicateError} If multiple records are found * @returns The proof record */ - public async getByConnectionAndThreadId(connectionId: string, threadId: string): Promise { + public async getByThreadAndConnectionId(threadId: string, connectionId?: string): Promise { return this.proofRepository.getSingleByQuery({ threadId, connectionId }) } + public update(proofRecord: ProofRecord) { + return this.proofRepository.update(proofRecord) + } + /** * Create indy proof from a given proof request and requested credential object. * @@ -974,6 +987,11 @@ export class ProofService { } } +export interface ProofRequestTemplate { + proofRequest: ProofRequest + comment?: string +} + export interface ProofProtocolMsgReturnType { message: MessageType proofRecord: ProofRecord diff --git a/packages/core/src/modules/routing/__tests__/RecipientService.test.ts b/packages/core/src/modules/routing/__tests__/RecipientService.test.ts deleted file mode 100644 index f41d91e7d1..0000000000 --- a/packages/core/src/modules/routing/__tests__/RecipientService.test.ts +++ /dev/null @@ -1,138 +0,0 @@ -import type { Wallet } from '../../../wallet/Wallet' - -import { assert } from 'console' - -import { getBaseConfig } from '../../../../tests/helpers' -import { AgentConfig } from '../../../agent/AgentConfig' -import { EventEmitter } from '../../../agent/EventEmitter' -import { MessageSender as MessageSenderImpl } from '../../../agent/MessageSender' -import { IndyWallet } from '../../../wallet/IndyWallet' -import { ConnectionService as ConnectionServiceImpl } from '../../connections/services/ConnectionService' -import { MediationRole, MediationState } from '../models' -import { MediationRecord } from '../repository' -import { MediationRepository } from '../repository/MediationRepository' -import { MediationRecipientService } from '../services/MediationRecipientService' -jest.mock('../services/MediationRecipientService') -jest.mock('./../../../storage/Repository') -const MediationRepositoryMock = MediationRepository as jest.Mock - -describe('Recipient', () => { - const ConnectionService = >(ConnectionServiceImpl) - const MessageSender = >(MessageSenderImpl) - const initConfig = getBaseConfig('MediationRecipientService') - - let wallet: Wallet - let agentConfig: AgentConfig - let mediationRepository: MediationRepository - let mediationRecipientService: MediationRecipientService - let eventEmitter: EventEmitter - - beforeAll(async () => { - agentConfig = new AgentConfig(initConfig.config, initConfig.agentDependencies) - wallet = new IndyWallet(agentConfig) - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await wallet.initialize(initConfig.config.walletConfig!, initConfig.config.walletCredentials!) - }) - - afterAll(async () => { - await wallet.delete() - }) - - beforeEach(() => { - mediationRepository = new MediationRepositoryMock() - eventEmitter = new EventEmitter(agentConfig) - mediationRecipientService = new MediationRecipientService( - wallet, - new ConnectionService(), - new MessageSender(), - agentConfig, - mediationRepository, - eventEmitter - ) - }) - - describe('MediationRecord test', () => { - it('validates mediation record class', () => { - const record = new MediationRecord({ - state: MediationState.Requested, - role: MediationRole.Recipient, - threadId: 'fakeThreadId', - connectionId: 'fakeConnectionId', - recipientKeys: ['fakeRecipientKey'], - tags: { - default: false, - }, - }) - assert(record.state, 'Expected MediationRecord to have an `state` property') - expect(record.state).toBeDefined() - assert(record.role, 'Expected MediationRecord to have an `role` property') - expect(record.role).toBeDefined() - assert(record.getTags(), 'Expected MediationRecord to have an `tags` property') - expect(record.getTags()).toBeDefined() - assert(record.getTags().state, 'Expected MediationRecord to have an `tags.state` property') - assert(record.getTags().role, 'Expected MediationRecord to have an `tags.role` property') - assert(record.getTags().connectionId, 'Expected MediationRecord to have an `tags.connectionId` property') - assert(record.connectionId, 'Expected MediationRecord to have an `connectionId` property') - expect(record.connectionId).toBeDefined() - assert(record.endpoint, 'Expected MediationRecord to have an `endpoint` property') - assert(record.recipientKeys, 'Expected MediationRecord to have an `recipientKeys` property') - expect(record.recipientKeys).toBeDefined() - assert(record.routingKeys, 'Expected MediationRecord to have an `routingKeys` property') - expect(record.routingKeys).toBeDefined() - }) - }) - describe('Recipient service tests', () => { - it('validate service class signiture', () => { - assert( - mediationRecipientService.setDefaultMediator, - 'Expected MediationRecipientService to have a `setDefaultMediator` method' - ) - assert( - mediationRecipientService.findDefaultMediator, - 'Expected MediationRecipientService to have a `getDefaultMediator` method' - ) - assert( - mediationRecipientService.getMediators, - 'Expected MediationRecipientService to have a `getMediators` method' - ) - assert( - mediationRecipientService.clearDefaultMediator, - 'Expected MediationRecipientService to have a `clearDefaultMediator` method' - ) - assert( - mediationRecipientService.findByConnectionId, - 'Expected MediationRecipientService to have a `findByConnectionId` method' - ) - assert( - mediationRecipientService.processMediationDeny, - 'Expected MediationRecipientService to have a `processMediationDeny` method' - ) - assert( - mediationRecipientService.processMediationGrant, - 'Expected MediationRecipientService to have a `processMediationGrant` method' - ) - assert( - mediationRecipientService.processKeylistUpdateResults, - 'Expected MediationRecipientService to have a `processKeylistUpdateResults` method' - ) - assert( - mediationRecipientService.createRequest, - 'Expected MediationRecipientService to have a `createRequest` method' - ) - //assert(service.createRecord, 'Expected MediationRecipientService to have a `createRecord` method') - }) - it.todo('setDefaultMediator adds changes tags on mediation records') - it.todo('getDefaultMediator returns mediation record with default tag set to "true"') - it.todo('getDefaultMediatorId returns id of the mediation record with default tag set to "true"') - it.todo('getMediators returns all mediation records') - it.todo('clearDefaultMediator sets all mediation record tags to "false"') - it.todo('findByConnectionId returns mediation record given a connectionId') - it.todo('findById returns mediation record given mediationId') - it.todo('processMediationDeny...') - it.todo('processMediationGrant...') - it.todo('processKeylistUpdateResults...') - it.todo('createKeylistQuery...') - it.todo('createRequest...') - it.todo('createRecord...') - }) -}) diff --git a/packages/core/src/modules/routing/__tests__/mediation.test.ts b/packages/core/src/modules/routing/__tests__/mediation.test.ts index 0c2856100a..7d665b7b8c 100644 --- a/packages/core/src/modules/routing/__tests__/mediation.test.ts +++ b/packages/core/src/modules/routing/__tests__/mediation.test.ts @@ -1,4 +1,4 @@ -import type { WireMessage } from '../../../types' +import type { SubjectMessage } from '../../../../../../tests/transport/SubjectInboundTransport' import { Subject } from 'rxjs' @@ -44,9 +44,9 @@ describe('mediator establishment', () => { 5. Assert endpoint in recipient invitation for sender is mediator endpoint 6. Send basic message from sender to recipient and assert it is received on the recipient side `, async () => { - const mediatorMessages = new Subject() - const recipientMessages = new Subject() - const senderMessages = new Subject() + const mediatorMessages = new Subject() + const recipientMessages = new Subject() + const senderMessages = new Subject() const subjectMap = { 'rxjs:mediator': mediatorMessages, diff --git a/packages/core/src/modules/routing/handlers/ForwardHandler.ts b/packages/core/src/modules/routing/handlers/ForwardHandler.ts index 687ff63ee8..217bfae8db 100644 --- a/packages/core/src/modules/routing/handlers/ForwardHandler.ts +++ b/packages/core/src/modules/routing/handlers/ForwardHandler.ts @@ -29,6 +29,6 @@ export class ForwardHandler implements Handler { // The message inside the forward message is packed so we just send the packed // message to the connection associated with it - await this.messageSender.sendMessage({ connection: connectionRecord, payload: packedMessage }) + await this.messageSender.sendPackage({ connection: connectionRecord, packedMessage }) } } diff --git a/packages/core/src/modules/routing/messages/ForwardMessage.ts b/packages/core/src/modules/routing/messages/ForwardMessage.ts index d9d6bc3c9c..7442083f1a 100644 --- a/packages/core/src/modules/routing/messages/ForwardMessage.ts +++ b/packages/core/src/modules/routing/messages/ForwardMessage.ts @@ -2,12 +2,12 @@ import { Expose } from 'class-transformer' import { Equals, IsString } from 'class-validator' import { AgentMessage } from '../../../agent/AgentMessage' -import { PackedMessage } from '../../../types' +import { WireMessage } from '../../../types' export interface ForwardMessageOptions { id?: string to: string - message: PackedMessage + message: WireMessage } /** @@ -37,5 +37,5 @@ export class ForwardMessage extends AgentMessage { public to!: string @Expose({ name: 'msg' }) - public message!: PackedMessage + public message!: WireMessage } diff --git a/packages/core/src/transport/HttpOutboundTransporter.ts b/packages/core/src/transport/HttpOutboundTransporter.ts index 964607a00b..4403c949b8 100644 --- a/packages/core/src/transport/HttpOutboundTransporter.ts +++ b/packages/core/src/transport/HttpOutboundTransporter.ts @@ -32,14 +32,14 @@ export class HttpOutboundTransporter implements OutboundTransporter { } public async sendMessage(outboundPackage: OutboundPackage) { - const { connection, payload, endpoint } = outboundPackage + const { payload, endpoint } = outboundPackage if (!endpoint) { throw new AriesFrameworkError(`Missing endpoint. I don't know how and where to send the message.`) } - this.logger.debug(`Sending outbound message to connection ${connection.id} (${connection.theirLabel})`, { - endpoint: endpoint, + this.logger.debug(`Sending outbound message to endpoint '${outboundPackage.endpoint}'`, { + payload: outboundPackage.payload, }) try { diff --git a/packages/core/src/transport/WsOutboundTransporter.ts b/packages/core/src/transport/WsOutboundTransporter.ts index 7a55a6b5d6..79aa53e41f 100644 --- a/packages/core/src/transport/WsOutboundTransporter.ts +++ b/packages/core/src/transport/WsOutboundTransporter.ts @@ -1,6 +1,5 @@ import type { Agent } from '../agent/Agent' import type { Logger } from '../logger' -import type { ConnectionRecord } from '../modules/connections' import type { OutboundPackage } from '../types' import type { OutboundTransporter } from './OutboundTransporter' import type WebSocket from 'ws' @@ -36,13 +35,18 @@ export class WsOutboundTransporter implements OutboundTransporter { } public async sendMessage(outboundPackage: OutboundPackage) { - const { connection, payload, endpoint } = outboundPackage - this.logger.debug( - `Sending outbound message to connection ${connection.id} (${connection.theirLabel}) over websocket transport.`, - payload - ) - const isNewSocket = this.hasOpenSocket(connection.id) - const socket = await this.resolveSocket(connection, endpoint) + const { payload, endpoint } = outboundPackage + this.logger.debug(`Sending outbound message to endpoint '${endpoint}' over WebSocket transport.`, { + payload, + }) + + if (!endpoint) { + throw new AriesFrameworkError("Missing connection or endpoint. I don't know how and where to send the message.") + } + + const isNewSocket = this.hasOpenSocket(endpoint) + const socket = await this.resolveSocket(endpoint, endpoint) + socket.send(JSON.stringify(payload)) // If the socket was created for this message and we don't have return routing enabled @@ -56,18 +60,16 @@ export class WsOutboundTransporter implements OutboundTransporter { return this.transportTable.get(socketId) !== undefined } - private async resolveSocket(connection: ConnectionRecord, endpoint?: string) { - const socketId = connection.id - + private async resolveSocket(socketIdentifier: string, endpoint?: string) { // If we already have a socket connection use it - let socket = this.transportTable.get(socketId) + let socket = this.transportTable.get(socketIdentifier) if (!socket) { if (!endpoint) { throw new AriesFrameworkError(`Missing endpoint. I don't know how and where to send the message.`) } - socket = await this.createSocketConnection(endpoint, socketId) - this.transportTable.set(socketId, socket) + socket = await this.createSocketConnection(endpoint, socketIdentifier) + this.transportTable.set(socketIdentifier, socket) this.listenOnWebSocketMessages(socket) } diff --git a/packages/core/src/types.ts b/packages/core/src/types.ts index a7921b211c..50a2df74f1 100644 --- a/packages/core/src/types.ts +++ b/packages/core/src/types.ts @@ -1,16 +1,17 @@ import type { AgentMessage } from './agent/AgentMessage' -import type { TransportSession } from './agent/TransportService' import type { Logger } from './logger' -import type { ConnectionRecord } from './modules/connections' +import type { ConnectionRecord, DidCommService } from './modules/connections' import type { AutoAcceptCredential } from './modules/credentials/CredentialAutoAcceptType' import type { AutoAcceptProof } from './modules/proofs' import type { MediatorPickupStrategy } from './modules/routing' import type { Verkey, WalletConfig, WalletCredentials } from 'indy-sdk' -// eslint-disable-next-line @typescript-eslint/no-explicit-any -type $FixMe = any - -export type WireMessage = $FixMe +export type WireMessage = { + protected: unknown + iv: unknown + ciphertext: unknown + tag: unknown +} export enum DidCommMimeType { V0 = 'application/ssi-agent-wire', @@ -50,23 +51,25 @@ export interface UnpackedMessage { export interface UnpackedMessageContext { message: UnpackedMessage - sender_verkey?: Verkey - recipient_verkey?: Verkey + senderVerkey?: Verkey + recipientVerkey?: Verkey } -export type PackedMessage = Record - export interface OutboundMessage { + payload: T connection: ConnectionRecord +} + +export interface OutboundServiceMessage { payload: T + service: DidCommService + senderKey: string } export interface OutboundPackage { - connection: ConnectionRecord payload: WireMessage responseRequested?: boolean endpoint?: string - session?: TransportSession } export interface InboundConnection { diff --git a/packages/core/src/wallet/IndyWallet.ts b/packages/core/src/wallet/IndyWallet.ts index ba4e90fde1..d9401ad79a 100644 --- a/packages/core/src/wallet/IndyWallet.ts +++ b/packages/core/src/wallet/IndyWallet.ts @@ -1,5 +1,5 @@ import type { Logger } from '../logger' -import type { PackedMessage, UnpackedMessageContext } from '../types' +import type { UnpackedMessageContext, WireMessage } from '../types' import type { Buffer } from '../utils/buffer' import type { Wallet, DidInfo } from './Wallet' import type { @@ -290,21 +290,18 @@ export class IndyWallet implements Wallet { return this.indy.createAndStoreMyDid(this.walletHandle, didConfig || {}) } - public async pack( - payload: Record, - recipientKeys: Verkey[], - senderVk: Verkey - ): Promise { + public async pack(payload: Record, recipientKeys: Verkey[], senderVk: Verkey): Promise { const messageRaw = JsonEncoder.toBuffer(payload) const packedMessage = await this.indy.packMessage(this.walletHandle, messageRaw, recipientKeys, senderVk) return JsonEncoder.fromBuffer(packedMessage) } - public async unpack(messagePackage: PackedMessage): Promise { + public async unpack(messagePackage: WireMessage): Promise { const unpackedMessageBuffer = await this.indy.unpackMessage(this.walletHandle, JsonEncoder.toBuffer(messagePackage)) const unpackedMessage = JsonEncoder.fromBuffer(unpackedMessageBuffer) return { - ...unpackedMessage, + recipientVerkey: unpackedMessage.recipient_verkey, + senderVerkey: unpackedMessage.sender_verkey, message: JsonEncoder.fromString(unpackedMessage.message), } } diff --git a/packages/core/src/wallet/Wallet.ts b/packages/core/src/wallet/Wallet.ts index d1dad0bd27..16835ad5c1 100644 --- a/packages/core/src/wallet/Wallet.ts +++ b/packages/core/src/wallet/Wallet.ts @@ -1,4 +1,4 @@ -import type { PackedMessage, UnpackedMessageContext } from '../types' +import type { UnpackedMessageContext, WireMessage } from '../types' import type { Buffer } from '../utils/buffer' import type { DidConfig, @@ -23,8 +23,8 @@ export interface Wallet { initPublicDid(didConfig: DidConfig): Promise createDid(didConfig?: DidConfig): Promise<[Did, Verkey]> - pack(payload: Record, recipientKeys: Verkey[], senderVk: Verkey | null): Promise - unpack(messagePackage: PackedMessage): Promise + pack(payload: Record, recipientKeys: Verkey[], senderVk: Verkey | null): Promise + unpack(messagePackage: WireMessage): Promise sign(data: Buffer, verkey: Verkey): Promise verify(signerVerkey: Verkey, data: Buffer, signature: Buffer): Promise addWalletRecord(type: string, id: string, value: string, tags: Record): Promise diff --git a/packages/core/tests/connectionless-credentials.test.ts b/packages/core/tests/connectionless-credentials.test.ts new file mode 100644 index 0000000000..954c71930c --- /dev/null +++ b/packages/core/tests/connectionless-credentials.test.ts @@ -0,0 +1,196 @@ +import type { SubjectMessage } from '../../../tests/transport/SubjectInboundTransport' +import type { CredentialStateChangedEvent } from '../src/modules/credentials' + +import { ReplaySubject, Subject } from 'rxjs' + +import { SubjectInboundTransporter } from '../../../tests/transport/SubjectInboundTransport' +import { SubjectOutboundTransporter } from '../../../tests/transport/SubjectOutboundTransport' +import { Agent } from '../src/agent/Agent' +import { + AutoAcceptCredential, + CredentialEventTypes, + CredentialRecord, + CredentialState, +} from '../src/modules/credentials' + +import { getBaseConfig, previewFromAttributes, prepareForIssuance, waitForCredentialRecordSubject } from './helpers' +import testLogger from './logger' + +const faberConfig = getBaseConfig('Faber connection-less Credentials', { + endpoint: 'rxjs:faber', +}) + +const aliceConfig = getBaseConfig('Alice connection-less Credentials', { + endpoint: 'rxjs:alice', +}) + +const credentialPreview = previewFromAttributes({ + name: 'John', + age: '99', +}) + +describe('credentials', () => { + let faberAgent: Agent + let aliceAgent: Agent + let faberReplay: ReplaySubject + let aliceReplay: ReplaySubject + let credDefId: string + + beforeEach(async () => { + const faberMessages = new Subject() + const aliceMessages = new Subject() + + const subjectMap = { + 'rxjs:faber': faberMessages, + 'rxjs:alice': aliceMessages, + } + faberAgent = new Agent(faberConfig.config, faberConfig.agentDependencies) + faberAgent.setInboundTransporter(new SubjectInboundTransporter(faberMessages)) + faberAgent.setOutboundTransporter(new SubjectOutboundTransporter(aliceMessages, subjectMap)) + await faberAgent.initialize() + + aliceAgent = new Agent(aliceConfig.config, aliceConfig.agentDependencies) + aliceAgent.setInboundTransporter(new SubjectInboundTransporter(aliceMessages)) + aliceAgent.setOutboundTransporter(new SubjectOutboundTransporter(faberMessages, subjectMap)) + await aliceAgent.initialize() + + const { definition } = await prepareForIssuance(faberAgent, ['name', 'age']) + credDefId = definition.id + + faberReplay = new ReplaySubject() + aliceReplay = new ReplaySubject() + + faberAgent.events + .observable(CredentialEventTypes.CredentialStateChanged) + .subscribe(faberReplay) + aliceAgent.events + .observable(CredentialEventTypes.CredentialStateChanged) + .subscribe(aliceReplay) + }) + + afterEach(async () => { + await faberAgent.shutdown({ deleteWallet: true }) + await aliceAgent.shutdown({ deleteWallet: true }) + }) + + test('Faber starts with connection-less credential offer to Alice', async () => { + testLogger.test('Faber sends credential offer to Alice') + // eslint-disable-next-line prefer-const + let { offerMessage, credentialRecord: faberCredentialRecord } = await faberAgent.credentials.createOutOfBandOffer({ + preview: credentialPreview, + credentialDefinitionId: credDefId, + comment: 'some comment about credential', + }) + + await aliceAgent.receiveMessage(offerMessage.toJSON()) + + let aliceCredentialRecord = await waitForCredentialRecordSubject(aliceReplay, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.OfferReceived, + }) + + testLogger.test('Alice sends credential request to Faber') + aliceCredentialRecord = await aliceAgent.credentials.acceptOffer(aliceCredentialRecord.id) + + testLogger.test('Faber waits for credential request from Alice') + faberCredentialRecord = await waitForCredentialRecordSubject(faberReplay, { + threadId: aliceCredentialRecord.threadId, + state: CredentialState.RequestReceived, + }) + + testLogger.test('Faber sends credential to Alice') + faberCredentialRecord = await faberAgent.credentials.acceptRequest(faberCredentialRecord.id) + + testLogger.test('Alice waits for credential from Faber') + aliceCredentialRecord = await waitForCredentialRecordSubject(aliceReplay, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.CredentialReceived, + }) + + testLogger.test('Alice sends credential ack to Faber') + aliceCredentialRecord = await aliceAgent.credentials.acceptCredential(aliceCredentialRecord.id) + + testLogger.test('Faber waits for credential ack from Alice') + faberCredentialRecord = await waitForCredentialRecordSubject(faberReplay, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.Done, + }) + + expect(aliceCredentialRecord).toMatchObject({ + type: CredentialRecord.name, + id: expect.any(String), + createdAt: expect.any(Date), + offerMessage: expect.any(Object), + requestMessage: expect.any(Object), + metadata: { requestMetadata: expect.any(Object) }, + credentialId: expect.any(String), + state: CredentialState.Done, + threadId: expect.any(String), + }) + + expect(faberCredentialRecord).toMatchObject({ + type: CredentialRecord.name, + id: expect.any(String), + createdAt: expect.any(Date), + offerMessage: expect.any(Object), + requestMessage: expect.any(Object), + state: CredentialState.Done, + threadId: expect.any(String), + }) + }) + + test('Faber starts with connection-less credential offer to Alice with auto-accept enabled', async () => { + // eslint-disable-next-line prefer-const + let { offerMessage, credentialRecord: faberCredentialRecord } = await faberAgent.credentials.createOutOfBandOffer({ + preview: credentialPreview, + credentialDefinitionId: credDefId, + comment: 'some comment about credential', + autoAcceptCredential: AutoAcceptCredential.ContentApproved, + }) + + // Receive Message + await aliceAgent.receiveMessage(offerMessage.toJSON()) + + // Wait for it to be processed + let aliceCredentialRecord = await waitForCredentialRecordSubject(aliceReplay, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.OfferReceived, + }) + + await aliceAgent.credentials.acceptOffer(aliceCredentialRecord.id, { + autoAcceptCredential: AutoAcceptCredential.ContentApproved, + }) + + aliceCredentialRecord = await waitForCredentialRecordSubject(aliceReplay, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.Done, + }) + + faberCredentialRecord = await waitForCredentialRecordSubject(faberReplay, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.Done, + }) + + expect(aliceCredentialRecord).toMatchObject({ + type: CredentialRecord.name, + id: expect.any(String), + createdAt: expect.any(Date), + offerMessage: expect.any(Object), + requestMessage: expect.any(Object), + metadata: { requestMetadata: expect.any(Object) }, + credentialId: expect.any(String), + state: CredentialState.Done, + threadId: expect.any(String), + }) + + expect(faberCredentialRecord).toMatchObject({ + type: CredentialRecord.name, + id: expect.any(String), + createdAt: expect.any(Date), + offerMessage: expect.any(Object), + requestMessage: expect.any(Object), + state: CredentialState.Done, + threadId: expect.any(String), + }) + }) +}) diff --git a/packages/core/tests/connectionless-proofs.test.ts b/packages/core/tests/connectionless-proofs.test.ts new file mode 100644 index 0000000000..bc5f795b62 --- /dev/null +++ b/packages/core/tests/connectionless-proofs.test.ts @@ -0,0 +1,147 @@ +import { + PredicateType, + ProofState, + ProofAttributeInfo, + AttributeFilter, + ProofPredicateInfo, + AutoAcceptProof, +} from '../src/modules/proofs' + +import { setupProofsTest, waitForProofRecordSubject } from './helpers' +import testLogger from './logger' + +describe('Present Proof', () => { + test('Faber starts with connection-less proof requests to Alice', async () => { + const { aliceAgent, faberAgent, aliceReplay, credDefId, faberReplay, presentationPreview } = await setupProofsTest( + 'Faber connection-less Proofs', + 'Alice connection-less Proofs', + AutoAcceptProof.Never + ) + testLogger.test('Faber sends presentation request to Alice') + + const attributes = { + name: new ProofAttributeInfo({ + name: 'name', + restrictions: [ + new AttributeFilter({ + credentialDefinitionId: credDefId, + }), + ], + }), + } + + const predicates = { + age: new ProofPredicateInfo({ + name: 'age', + predicateType: PredicateType.GreaterThanOrEqualTo, + predicateValue: 50, + restrictions: [ + new AttributeFilter({ + credentialDefinitionId: credDefId, + }), + ], + }), + } + + // eslint-disable-next-line prefer-const + let { proofRecord: faberProofRecord, requestMessage } = await faberAgent.proofs.createOutOfBandRequest({ + name: 'test-proof-request', + requestedAttributes: attributes, + requestedPredicates: predicates, + }) + + await aliceAgent.receiveMessage(requestMessage.toJSON()) + + testLogger.test('Alice waits for presentation request from Faber') + let aliceProofRecord = await waitForProofRecordSubject(aliceReplay, { + threadId: faberProofRecord.threadId, + state: ProofState.RequestReceived, + }) + + testLogger.test('Alice accepts presentation request from Faber') + const indyProofRequest = aliceProofRecord.requestMessage?.indyProofRequest + const retrievedCredentials = await aliceAgent.proofs.getRequestedCredentialsForProofRequest( + // eslint-disable-next-line @typescript-eslint/no-non-null-assertion + indyProofRequest!, + presentationPreview + ) + const requestedCredentials = aliceAgent.proofs.autoSelectCredentialsForProofRequest(retrievedCredentials) + await aliceAgent.proofs.acceptRequest(aliceProofRecord.id, requestedCredentials) + + testLogger.test('Faber waits for presentation from Alice') + faberProofRecord = await waitForProofRecordSubject(faberReplay, { + threadId: aliceProofRecord.threadId, + state: ProofState.PresentationReceived, + }) + + // assert presentation is valid + expect(faberProofRecord.isVerified).toBe(true) + + // Faber accepts presentation + await faberAgent.proofs.acceptPresentation(faberProofRecord.id) + + // Alice waits till it receives presentation ack + aliceProofRecord = await waitForProofRecordSubject(aliceReplay, { + threadId: aliceProofRecord.threadId, + state: ProofState.Done, + }) + }) + + test('Faber starts with connection-less proof requests to Alice with auto-accept enabled', async () => { + testLogger.test('Faber sends presentation request to Alice') + + const { aliceAgent, faberAgent, aliceReplay, credDefId, faberReplay } = await setupProofsTest( + 'Faber connection-less Proofs - Auto Accept', + 'Alice connection-less Proofs - Auto Accept', + AutoAcceptProof.Always + ) + + const attributes = { + name: new ProofAttributeInfo({ + name: 'name', + restrictions: [ + new AttributeFilter({ + credentialDefinitionId: credDefId, + }), + ], + }), + } + + const predicates = { + age: new ProofPredicateInfo({ + name: 'age', + predicateType: PredicateType.GreaterThanOrEqualTo, + predicateValue: 50, + restrictions: [ + new AttributeFilter({ + credentialDefinitionId: credDefId, + }), + ], + }), + } + + // eslint-disable-next-line prefer-const + let { proofRecord: faberProofRecord, requestMessage } = await faberAgent.proofs.createOutOfBandRequest( + { + name: 'test-proof-request', + requestedAttributes: attributes, + requestedPredicates: predicates, + }, + { + autoAcceptProof: AutoAcceptProof.ContentApproved, + } + ) + + await aliceAgent.receiveMessage(requestMessage.toJSON()) + + await waitForProofRecordSubject(aliceReplay, { + threadId: faberProofRecord.threadId, + state: ProofState.Done, + }) + + await waitForProofRecordSubject(faberReplay, { + threadId: faberProofRecord.threadId, + state: ProofState.Done, + }) + }) +}) diff --git a/packages/core/tests/credentials-auto-accept.test.ts b/packages/core/tests/credentials-auto-accept.test.ts index e5d04ce2d1..525db49024 100644 --- a/packages/core/tests/credentials-auto-accept.test.ts +++ b/packages/core/tests/credentials-auto-accept.test.ts @@ -1,52 +1,22 @@ import type { Agent } from '../src/agent/Agent' import type { ConnectionRecord } from '../src/modules/connections' -import { - AutoAcceptCredential, - CredentialPreview, - CredentialPreviewAttribute, - CredentialRecord, - CredentialState, -} from '../src/modules/credentials' +import { AutoAcceptCredential, CredentialRecord, CredentialState } from '../src/modules/credentials' import { JsonTransformer } from '../src/utils/JsonTransformer' import { sleep } from '../src/utils/sleep' -import { setupCredentialTests, waitForCredentialRecord } from './helpers' +import { previewFromAttributes, setupCredentialTests, waitForCredentialRecord } from './helpers' import testLogger from './logger' -const credentialPreview = new CredentialPreview({ - attributes: [ - new CredentialPreviewAttribute({ - name: 'name', - mimeType: 'text/plain', - value: 'John', - }), - new CredentialPreviewAttribute({ - name: 'age', - mimeType: 'text/plain', - value: '99', - }), - ], +const credentialPreview = previewFromAttributes({ + name: 'John', + age: '99', }) -const newCredentialPreview = new CredentialPreview({ - attributes: [ - new CredentialPreviewAttribute({ - name: 'name', - mimeType: 'text/plain', - value: 'John', - }), - new CredentialPreviewAttribute({ - name: 'age', - mimeType: 'text/plain', - value: '99', - }), - new CredentialPreviewAttribute({ - name: 'lastname', - mimeType: 'text/plain', - value: 'Appleseed', - }), - ], +const newCredentialPreview = previewFromAttributes({ + name: 'John', + age: '99', + lastname: 'Appleseed', }) describe('auto accept credentials', () => { @@ -265,12 +235,10 @@ describe('auto accept credentials', () => { attributes: [ { name: 'name', - 'mime-type': 'text/plain', value: 'John', }, { name: 'age', - 'mime-type': 'text/plain', value: '99', }, ], @@ -360,17 +328,14 @@ describe('auto accept credentials', () => { attributes: [ { name: 'name', - 'mime-type': 'text/plain', value: 'John', }, { name: 'age', - 'mime-type': 'text/plain', value: '99', }, { name: 'lastname', - 'mime-type': 'text/plain', value: 'Appleseed', }, ], @@ -423,12 +388,10 @@ describe('auto accept credentials', () => { attributes: [ { name: 'name', - 'mime-type': 'text/plain', value: 'John', }, { name: 'age', - 'mime-type': 'text/plain', value: '99', }, ], @@ -463,17 +426,14 @@ describe('auto accept credentials', () => { attributes: [ { name: 'name', - 'mime-type': 'text/plain', value: 'John', }, { name: 'age', - 'mime-type': 'text/plain', value: '99', }, { name: 'lastname', - 'mime-type': 'text/plain', value: 'Appleseed', }, ], diff --git a/packages/core/tests/helpers.ts b/packages/core/tests/helpers.ts index dfff9b0e3f..375a2fc4d5 100644 --- a/packages/core/tests/helpers.ts +++ b/packages/core/tests/helpers.ts @@ -1,24 +1,24 @@ import type { SubjectMessage } from '../../../tests/transport/SubjectInboundTransport' import type { - InitConfig, - ProofRecord, - ProofStateChangedEvent, - CredentialRecord, - CredentialStateChangedEvent, + AutoAcceptProof, BasicMessage, BasicMessageReceivedEvent, ConnectionRecordProps, - SchemaTemplate, CredentialDefinitionTemplate, CredentialOfferTemplate, + CredentialStateChangedEvent, + InitConfig, ProofAttributeInfo, ProofPredicateInfo, - AutoAcceptProof, + ProofStateChangedEvent, + SchemaTemplate, } from '../src' import type { Schema, CredDef, Did } from 'indy-sdk' +import type { Observable } from 'rxjs' import path from 'path' -import { Subject } from 'rxjs' +import { firstValueFrom, Subject, ReplaySubject } from 'rxjs' +import { catchError, filter, map, timeout } from 'rxjs/operators' import { SubjectInboundTransporter } from '../../../tests/transport/SubjectInboundTransport' import { SubjectOutboundTransporter } from '../../../tests/transport/SubjectOutboundTransport' @@ -26,28 +26,28 @@ import { agentDependencies } from '../../node/src' import { LogLevel, AgentConfig, - ProofState, - ProofEventTypes, - CredentialState, - CredentialEventTypes, + AriesFrameworkError, BasicMessageEventTypes, - ConnectionState, - ConnectionRole, - DidDoc, - DidCommService, ConnectionInvitationMessage, ConnectionRecord, + ConnectionRole, + ConnectionState, + CredentialEventTypes, CredentialPreview, CredentialPreviewAttribute, - AriesFrameworkError, - AutoAcceptCredential, + CredentialState, + DidCommService, + DidDoc, + PredicateType, PresentationPreview, PresentationPreviewAttribute, PresentationPreviewPredicate, - PredicateType, + ProofEventTypes, + ProofState, Agent, } from '../src' import { Attachment, AttachmentData } from '../src/decorators/attachment/Attachment' +import { AutoAcceptCredential } from '../src/modules/credentials/CredentialAutoAcceptType' import { LinkedAttachment } from '../src/utils/LinkedAttachment' import { uuid } from '../src/utils/uuid' @@ -82,60 +82,98 @@ export function getAgentConfig(name: string, extraConfig: Partial = export async function waitForProofRecord( agent: Agent, + options: { + threadId?: string + state?: ProofState + previousState?: ProofState | null + timeoutMs?: number + } +) { + const observable = agent.events.observable(ProofEventTypes.ProofStateChanged) + + return waitForProofRecordSubject(observable, options) +} + +export function waitForProofRecordSubject( + subject: ReplaySubject | Observable, { threadId, state, previousState, + timeoutMs = 5000, }: { threadId?: string state?: ProofState previousState?: ProofState | null + timeoutMs?: number } -): Promise { - return new Promise((resolve) => { - const listener = (event: ProofStateChangedEvent) => { - const previousStateMatches = previousState === undefined || event.payload.previousState === previousState - const threadIdMatches = threadId === undefined || event.payload.proofRecord.threadId === threadId - const stateMatches = state === undefined || event.payload.proofRecord.state === state - - if (previousStateMatches && threadIdMatches && stateMatches) { - agent.events.off(ProofEventTypes.ProofStateChanged, listener) - - resolve(event.payload.proofRecord) - } - } - - agent.events.on(ProofEventTypes.ProofStateChanged, listener) - }) +) { + const observable = subject instanceof ReplaySubject ? subject.asObservable() : subject + return firstValueFrom( + observable.pipe( + filter((e) => previousState === undefined || e.payload.previousState === previousState), + filter((e) => threadId === undefined || e.payload.proofRecord.threadId === threadId), + filter((e) => state === undefined || e.payload.proofRecord.state === state), + timeout(timeoutMs), + catchError(() => { + throw new Error( + `ProofStateChangedEvent event not emitted within specified timeout: { + previousState: ${previousState}, + threadId: ${threadId}, + state: ${state} +}` + ) + }), + map((e) => e.payload.proofRecord) + ) + ) } -export async function waitForCredentialRecord( - agent: Agent, +export function waitForCredentialRecordSubject( + subject: ReplaySubject | Observable, { threadId, state, previousState, + timeoutMs = 5000, }: { threadId?: string state?: CredentialState previousState?: CredentialState | null + timeoutMs?: number } -): Promise { - return new Promise((resolve) => { - const listener = (event: CredentialStateChangedEvent) => { - const previousStateMatches = previousState === undefined || event.payload.previousState === previousState - const threadIdMatches = threadId === undefined || event.payload.credentialRecord.threadId === threadId - const stateMatches = state === undefined || event.payload.credentialRecord.state === state - - if (previousStateMatches && threadIdMatches && stateMatches) { - agent.events.off(CredentialEventTypes.CredentialStateChanged, listener) +) { + const observable = subject instanceof ReplaySubject ? subject.asObservable() : subject + return firstValueFrom( + observable.pipe( + filter((e) => previousState === undefined || e.payload.previousState === previousState), + filter((e) => threadId === undefined || e.payload.credentialRecord.threadId === threadId), + filter((e) => state === undefined || e.payload.credentialRecord.state === state), + timeout(timeoutMs), + catchError(() => { + throw new Error(`CredentialStateChanged event not emitted within specified timeout: { + previousState: ${previousState}, + threadId: ${threadId}, + state: ${state} +}`) + }), + map((e) => e.payload.credentialRecord) + ) + ) +} - resolve(event.payload.credentialRecord) - } - } +export async function waitForCredentialRecord( + agent: Agent, + options: { + threadId?: string + state?: CredentialState + previousState?: CredentialState | null + timeoutMs?: number + } +) { + const observable = agent.events.observable(CredentialEventTypes.CredentialStateChanged) - agent.events.on(CredentialEventTypes.CredentialStateChanged, listener) - }) + return waitForCredentialRecordSubject(observable, options) } export async function waitForBasicMessage(agent: Agent, { content }: { content?: string }): Promise { @@ -174,6 +212,7 @@ export function getMockConnection({ ], }), tags = {}, + theirLabel, invitation = new ConnectionInvitationMessage({ label: 'test', recipientKeys: [verkey], @@ -205,6 +244,7 @@ export function getMockConnection({ tags, verkey, invitation, + theirLabel, }) } @@ -338,6 +378,49 @@ export async function issueCredential({ } } +export async function issueConnectionLessCredential({ + issuerAgent, + holderAgent, + credentialTemplate, +}: { + issuerAgent: Agent + holderAgent: Agent + credentialTemplate: Omit +}) { + // eslint-disable-next-line prefer-const + let { credentialRecord: issuerCredentialRecord, offerMessage } = await issuerAgent.credentials.createOutOfBandOffer({ + ...credentialTemplate, + autoAcceptCredential: AutoAcceptCredential.ContentApproved, + }) + + const credentialRecordPromise = waitForCredentialRecord(holderAgent, { + threadId: issuerCredentialRecord.threadId, + state: CredentialState.OfferReceived, + }) + + await holderAgent.receiveMessage(offerMessage.toJSON()) + let holderCredentialRecord = await credentialRecordPromise + + holderCredentialRecord = await holderAgent.credentials.acceptOffer(holderCredentialRecord.id, { + autoAcceptCredential: AutoAcceptCredential.ContentApproved, + }) + + holderCredentialRecord = await waitForCredentialRecord(holderAgent, { + threadId: issuerCredentialRecord.threadId, + state: CredentialState.Done, + }) + + issuerCredentialRecord = await waitForCredentialRecord(issuerAgent, { + threadId: issuerCredentialRecord.threadId, + state: CredentialState.Done, + }) + + return { + issuerCredential: issuerCredentialRecord, + holderCredential: holderCredentialRecord, + } +} + export async function presentProof({ verifierAgent, verifierConnectionId, @@ -450,28 +533,20 @@ export async function setupCredentialTests( } export async function setupProofsTest(faberName: string, aliceName: string, autoAcceptProofs?: AutoAcceptProof) { - const credentialPreview = new CredentialPreview({ - attributes: [ - new CredentialPreviewAttribute({ - name: 'name', - mimeType: 'text/plain', - value: 'John', - }), - new CredentialPreviewAttribute({ - name: 'age', - mimeType: 'text/plain', - value: '99', - }), - ], + const credentialPreview = previewFromAttributes({ + name: 'John', + age: '99', }) - const faberConfig = getBaseConfig(faberName, { + const unique = uuid().substring(0, 4) + + const faberConfig = getBaseConfig(`${faberName}-${unique}`, { genesisPath, autoAcceptProofs, endpoint: 'rxjs:faber', }) - const aliceConfig = getBaseConfig(aliceName, { + const aliceConfig = getBaseConfig(`${aliceName}-${unique}`, { genesisPath, autoAcceptProofs, endpoint: 'rxjs:alice', @@ -494,25 +569,8 @@ export async function setupProofsTest(faberName: string, aliceName: string, auto aliceAgent.setOutboundTransporter(new SubjectOutboundTransporter(faberMessages, subjectMap)) await aliceAgent.initialize() - const schemaTemplate = { - name: `test-schema-${Date.now()}`, - attributes: ['name', 'age', 'image_0', 'image_1'], - version: '1.0', - } - const schema = await registerSchema(faberAgent, schemaTemplate) + const { definition } = await prepareForIssuance(faberAgent, ['name', 'age', 'image_0', 'image_1']) - const definitionTemplate = { - schema, - tag: 'TAG', - signatureType: 'CL' as const, - supportRevocation: false, - } - const credentialDefinition = await registerDefinition(faberAgent, definitionTemplate) - const credDefId = credentialDefinition.id - - const publicDid = faberAgent.publicDid?.did - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - await ensurePublicDidIsOnLedger(faberAgent, publicDid!) const [agentAConnection, agentBConnection] = await makeConnection(faberAgent, aliceAgent) expect(agentAConnection.isReady).toBe(true) expect(agentBConnection.isReady).toBe(true) @@ -524,19 +582,19 @@ export async function setupProofsTest(faberName: string, aliceName: string, auto attributes: [ new PresentationPreviewAttribute({ name: 'name', - credentialDefinitionId: credDefId, + credentialDefinitionId: definition.id, referent: '0', value: 'John', }), new PresentationPreviewAttribute({ name: 'image_0', - credentialDefinitionId: credDefId, + credentialDefinitionId: definition.id, }), ], predicates: [ new PresentationPreviewPredicate({ name: 'age', - credentialDefinitionId: credDefId, + credentialDefinitionId: definition.id, predicate: PredicateType.GreaterThanOrEqualTo, threshold: 50, }), @@ -548,7 +606,7 @@ export async function setupProofsTest(faberName: string, aliceName: string, auto issuerConnectionId: faberConnection.id, holderAgent: aliceAgent, credentialTemplate: { - credentialDefinitionId: credDefId, + credentialDefinitionId: definition.id, comment: 'some comment about credential', preview: credentialPreview, linkedAttachments: [ @@ -570,5 +628,20 @@ export async function setupProofsTest(faberName: string, aliceName: string, auto }, }) - return { faberAgent, aliceAgent, credDefId, faberConnection, aliceConnection, presentationPreview } + const faberReplay = new ReplaySubject() + const aliceReplay = new ReplaySubject() + + faberAgent.events.observable(ProofEventTypes.ProofStateChanged).subscribe(faberReplay) + aliceAgent.events.observable(ProofEventTypes.ProofStateChanged).subscribe(aliceReplay) + + return { + faberAgent, + aliceAgent, + credDefId: definition.id, + faberConnection, + aliceConnection, + presentationPreview, + faberReplay, + aliceReplay, + } } diff --git a/packages/core/tests/proofs-auto-accept.test.ts b/packages/core/tests/proofs-auto-accept.test.ts index 943dd2406c..6fa36a61ea 100644 --- a/packages/core/tests/proofs-auto-accept.test.ts +++ b/packages/core/tests/proofs-auto-accept.test.ts @@ -24,7 +24,11 @@ describe('Auto accept present proof', () => { describe('Auto accept on `always`', () => { beforeAll(async () => { ;({ faberAgent, aliceAgent, credDefId, faberConnection, aliceConnection, presentationPreview } = - await setupProofsTest('faber agent always', 'alice agent always', AutoAcceptProof.Always)) + await setupProofsTest( + 'Faber Auto Accept Always Proofs', + 'Alice Auto Accept Always Proofs', + AutoAcceptProof.Always + )) }) afterAll(async () => { @@ -103,7 +107,11 @@ describe('Auto accept present proof', () => { describe('Auto accept on `contentApproved`', () => { beforeAll(async () => { ;({ faberAgent, aliceAgent, credDefId, faberConnection, aliceConnection, presentationPreview } = - await setupProofsTest('faber agent', 'alice agent', AutoAcceptProof.ContentApproved)) + await setupProofsTest( + 'Faber Auto Accept Content Approved Proofs', + 'Alice Auto Accept Content Approved Proofs', + AutoAcceptProof.ContentApproved + )) }) afterAll(async () => { diff --git a/packages/node/src/transport/HttpInboundTransport.ts b/packages/node/src/transport/HttpInboundTransport.ts index fc4d5cdd6e..117a0b56d4 100644 --- a/packages/node/src/transport/HttpInboundTransport.ts +++ b/packages/node/src/transport/HttpInboundTransport.ts @@ -1,4 +1,4 @@ -import type { InboundTransporter, Agent, OutboundPackage, TransportSession } from '@aries-framework/core' +import type { InboundTransporter, Agent, TransportSession, WireMessage } from '@aries-framework/core' import type { Express, Request, Response } from 'express' import type { Server } from 'http' @@ -72,11 +72,13 @@ export class HttpTransportSession implements TransportSession { this.res = res } - public async send(outboundMessage: OutboundPackage): Promise { + public async send(wireMessage: WireMessage): Promise { if (this.res.headersSent) { throw new AriesFrameworkError(`${this.type} transport session has been closed.`) } - this.res.status(200).json(outboundMessage.payload).end() + // FIXME: we should not use json(), but rather the correct + // DIDComm content-type based on the req and agent config + this.res.status(200).json(wireMessage).end() } } diff --git a/packages/node/src/transport/WsInboundTransport.ts b/packages/node/src/transport/WsInboundTransport.ts index 49d1a97673..f210d91e3f 100644 --- a/packages/node/src/transport/WsInboundTransport.ts +++ b/packages/node/src/transport/WsInboundTransport.ts @@ -1,4 +1,4 @@ -import type { Agent, InboundTransporter, Logger, OutboundPackage, TransportSession } from '@aries-framework/core' +import type { Agent, InboundTransporter, Logger, TransportSession, WireMessage } from '@aries-framework/core' import { AriesFrameworkError, AgentConfig, TransportService, utils } from '@aries-framework/core' import WebSocket, { Server } from 'ws' @@ -80,12 +80,11 @@ export class WebSocketTransportSession implements TransportSession { this.socket = socket } - public async send(outboundMessage: OutboundPackage): Promise { - // logger.debug(`Sending outbound message via ${this.type} transport session`) + public async send(wireMessage: WireMessage): Promise { if (this.socket.readyState !== WebSocket.OPEN) { throw new AriesFrameworkError(`${this.type} transport session has been closed.`) } - this.socket.send(JSON.stringify(outboundMessage.payload)) + this.socket.send(JSON.stringify(wireMessage)) } } diff --git a/tests/e2e-http.test.ts b/tests/e2e-http.test.ts new file mode 100644 index 0000000000..eeefc1042a --- /dev/null +++ b/tests/e2e-http.test.ts @@ -0,0 +1,63 @@ +import { getBaseConfig } from '../packages/core/tests/helpers' + +import { e2eTest } from './e2e-test' + +import { HttpOutboundTransporter, Agent, AutoAcceptCredential } from '@aries-framework/core' +import { HttpInboundTransport } from '@aries-framework/node' + +const recipientConfig = getBaseConfig('E2E HTTP Recipient', { + autoAcceptCredentials: AutoAcceptCredential.ContentApproved, +}) + +const mediatorPort = 3000 +const mediatorConfig = getBaseConfig('E2E HTTP Mediator', { + endpoint: `http://localhost:${mediatorPort}`, + autoAcceptMediationRequests: true, +}) + +const senderPort = 3001 +const senderConfig = getBaseConfig('E2E HTTP Sender', { + endpoint: `http://localhost:${senderPort}`, + mediatorPollingInterval: 1000, + autoAcceptCredentials: AutoAcceptCredential.ContentApproved, +}) + +describe('E2E HTTP tests', () => { + let recipientAgent: Agent + let mediatorAgent: Agent + let senderAgent: Agent + + beforeEach(async () => { + recipientAgent = new Agent(recipientConfig.config, recipientConfig.agentDependencies) + mediatorAgent = new Agent(mediatorConfig.config, mediatorConfig.agentDependencies) + senderAgent = new Agent(senderConfig.config, senderConfig.agentDependencies) + }) + + afterEach(async () => { + await recipientAgent.shutdown({ deleteWallet: true }) + await mediatorAgent.shutdown({ deleteWallet: true }) + await senderAgent.shutdown({ deleteWallet: true }) + }) + + test('Full HTTP flow (connect, request mediation, issue, verify)', async () => { + // Recipient Setup + recipientAgent.setOutboundTransporter(new HttpOutboundTransporter()) + await recipientAgent.initialize() + + // Mediator Setup + mediatorAgent.setInboundTransporter(new HttpInboundTransport({ port: mediatorPort })) + mediatorAgent.setOutboundTransporter(new HttpOutboundTransporter()) + await mediatorAgent.initialize() + + // Sender Setup + senderAgent.setInboundTransporter(new HttpInboundTransport({ port: senderPort })) + senderAgent.setOutboundTransporter(new HttpOutboundTransporter()) + await senderAgent.initialize() + + await e2eTest({ + mediatorAgent, + senderAgent, + recipientAgent, + }) + }) +}) diff --git a/tests/e2e-subject.test.ts b/tests/e2e-subject.test.ts new file mode 100644 index 0000000000..3137ee7b5c --- /dev/null +++ b/tests/e2e-subject.test.ts @@ -0,0 +1,74 @@ +import type { SubjectMessage } from './transport/SubjectInboundTransport' + +import { Subject } from 'rxjs' + +import { getBaseConfig } from '../packages/core/tests/helpers' + +import { e2eTest } from './e2e-test' +import { SubjectInboundTransporter } from './transport/SubjectInboundTransport' +import { SubjectOutboundTransporter } from './transport/SubjectOutboundTransport' + +import { Agent, AutoAcceptCredential } from '@aries-framework/core' + +const recipientConfig = getBaseConfig('E2E Subject Recipient', { + autoAcceptCredentials: AutoAcceptCredential.ContentApproved, +}) +const mediatorConfig = getBaseConfig('E2E Subject Mediator', { + endpoint: 'rxjs:mediator', + autoAcceptMediationRequests: true, +}) +const senderConfig = getBaseConfig('E2E Subject Sender', { + endpoint: 'rxjs:sender', + mediatorPollingInterval: 1000, + autoAcceptCredentials: AutoAcceptCredential.ContentApproved, +}) + +describe('E2E Subject tests', () => { + let recipientAgent: Agent + let mediatorAgent: Agent + let senderAgent: Agent + + beforeEach(async () => { + recipientAgent = new Agent(recipientConfig.config, recipientConfig.agentDependencies) + mediatorAgent = new Agent(mediatorConfig.config, mediatorConfig.agentDependencies) + senderAgent = new Agent(senderConfig.config, senderConfig.agentDependencies) + }) + + afterEach(async () => { + await recipientAgent.shutdown({ deleteWallet: true }) + await mediatorAgent.shutdown({ deleteWallet: true }) + await senderAgent.shutdown({ deleteWallet: true }) + }) + + test('Full Subject flow (connect, request mediation, issue, verify)', async () => { + const mediatorMessages = new Subject() + const recipientMessages = new Subject() + const senderMessages = new Subject() + + const subjectMap = { + 'rxjs:mediator': mediatorMessages, + 'rxjs:sender': senderMessages, + } + + // Recipient Setup + recipientAgent.setOutboundTransporter(new SubjectOutboundTransporter(recipientMessages, subjectMap)) + recipientAgent.setInboundTransporter(new SubjectInboundTransporter(recipientMessages)) + await recipientAgent.initialize() + + // Mediator Setup + mediatorAgent.setOutboundTransporter(new SubjectOutboundTransporter(mediatorMessages, subjectMap)) + mediatorAgent.setInboundTransporter(new SubjectInboundTransporter(mediatorMessages)) + await mediatorAgent.initialize() + + // Sender Setup + senderAgent.setOutboundTransporter(new SubjectOutboundTransporter(senderMessages, subjectMap)) + senderAgent.setInboundTransporter(new SubjectInboundTransporter(senderMessages)) + await senderAgent.initialize() + + await e2eTest({ + mediatorAgent, + senderAgent, + recipientAgent, + }) + }) +}) diff --git a/tests/e2e-test.ts b/tests/e2e-test.ts new file mode 100644 index 0000000000..e444717f2a --- /dev/null +++ b/tests/e2e-test.ts @@ -0,0 +1,98 @@ +import type { Agent } from '@aries-framework/core' + +import { + issueCredential, + makeConnection, + prepareForIssuance, + presentProof, + previewFromAttributes, +} from '../packages/core/tests/helpers' + +import { + AttributeFilter, + CredentialState, + MediationState, + PredicateType, + ProofAttributeInfo, + ProofPredicateInfo, + ProofState, +} from '@aries-framework/core' + +export async function e2eTest({ + mediatorAgent, + recipientAgent, + senderAgent, +}: { + mediatorAgent: Agent + recipientAgent: Agent + senderAgent: Agent +}) { + // Make connection between mediator and recipient + const [mediatorRecipientConnection, recipientMediatorConnection] = await makeConnection(mediatorAgent, recipientAgent) + expect(recipientMediatorConnection).toBeConnectedWith(mediatorRecipientConnection) + + // Request mediation from mediator + const mediationRecord = await recipientAgent.mediationRecipient.requestAndAwaitGrant(recipientMediatorConnection) + expect(mediationRecord.state).toBe(MediationState.Granted) + + // Set mediator as default for recipient, start picking up messages + await recipientAgent.mediationRecipient.setDefaultMediator(mediationRecord) + await recipientAgent.mediationRecipient.initiateMessagePickup(mediationRecord) + const defaultMediator = await recipientAgent.mediationRecipient.findDefaultMediator() + expect(defaultMediator?.id).toBe(mediationRecord.id) + + // Make connection between sender and recipient + const [recipientSenderConnection, senderRecipientConnection] = await makeConnection(recipientAgent, senderAgent) + expect(recipientSenderConnection).toBeConnectedWith(senderRecipientConnection) + + // Issue credential from sender to recipient + const { definition } = await prepareForIssuance(senderAgent, ['name', 'age', 'dateOfBirth']) + const { holderCredential, issuerCredential } = await issueCredential({ + issuerAgent: senderAgent, + holderAgent: recipientAgent, + issuerConnectionId: senderRecipientConnection.id, + credentialTemplate: { + credentialDefinitionId: definition.id, + preview: previewFromAttributes({ + name: 'John', + age: '25', + // year month day + dateOfBirth: '19950725', + }), + }, + }) + + expect(holderCredential.state).toBe(CredentialState.Done) + expect(issuerCredential.state).toBe(CredentialState.Done) + + // Present Proof from recipient to sender + const definitionRestriction = [ + new AttributeFilter({ + credentialDefinitionId: definition.id, + }), + ] + const { holderProof, verifierProof } = await presentProof({ + verifierAgent: senderAgent, + holderAgent: recipientAgent, + verifierConnectionId: senderRecipientConnection.id, + presentationTemplate: { + attributes: { + name: new ProofAttributeInfo({ + name: 'name', + restrictions: definitionRestriction, + }), + }, + predicates: { + olderThan21: new ProofPredicateInfo({ + name: 'age', + restrictions: definitionRestriction, + predicateType: PredicateType.LessThan, + predicateValue: 20000712, + }), + }, + }, + }) + + expect(holderProof.state).toBe(ProofState.Done) + expect(verifierProof.state).toBe(ProofState.Done) +} diff --git a/tests/e2e-ws.test.ts b/tests/e2e-ws.test.ts new file mode 100644 index 0000000000..d4f80e4d70 --- /dev/null +++ b/tests/e2e-ws.test.ts @@ -0,0 +1,63 @@ +import { getBaseConfig } from '../packages/core/tests/helpers' + +import { e2eTest } from './e2e-test' + +import { Agent, WsOutboundTransporter, AutoAcceptCredential } from '@aries-framework/core' +import { WsInboundTransport } from '@aries-framework/node' + +const recipientConfig = getBaseConfig('E2E WS Recipient ', { + autoAcceptCredentials: AutoAcceptCredential.ContentApproved, +}) + +const mediatorPort = 4000 +const mediatorConfig = getBaseConfig('E2E WS Mediator', { + endpoint: `ws://localhost:${mediatorPort}`, + autoAcceptMediationRequests: true, +}) + +const senderPort = 4001 +const senderConfig = getBaseConfig('E2E WS Sender', { + endpoint: `ws://localhost:${senderPort}`, + mediatorPollingInterval: 1000, + autoAcceptCredentials: AutoAcceptCredential.ContentApproved, +}) + +describe('E2E WS tests', () => { + let recipientAgent: Agent + let mediatorAgent: Agent + let senderAgent: Agent + + beforeEach(async () => { + recipientAgent = new Agent(recipientConfig.config, recipientConfig.agentDependencies) + mediatorAgent = new Agent(mediatorConfig.config, mediatorConfig.agentDependencies) + senderAgent = new Agent(senderConfig.config, senderConfig.agentDependencies) + }) + + afterEach(async () => { + await recipientAgent.shutdown({ deleteWallet: true }) + await mediatorAgent.shutdown({ deleteWallet: true }) + await senderAgent.shutdown({ deleteWallet: true }) + }) + + test('Full WS flow (connect, request mediation, issue, verify)', async () => { + // Recipient Setup + recipientAgent.setOutboundTransporter(new WsOutboundTransporter()) + await recipientAgent.initialize() + + // Mediator Setup + mediatorAgent.setInboundTransporter(new WsInboundTransport({ port: mediatorPort })) + mediatorAgent.setOutboundTransporter(new WsOutboundTransporter()) + await mediatorAgent.initialize() + + // Sender Setup + senderAgent.setInboundTransporter(new WsInboundTransport({ port: senderPort })) + senderAgent.setOutboundTransporter(new WsOutboundTransporter()) + await senderAgent.initialize() + + await e2eTest({ + mediatorAgent, + senderAgent, + recipientAgent, + }) + }) +}) diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts deleted file mode 100644 index f2aea949ec..0000000000 --- a/tests/e2e.test.ts +++ /dev/null @@ -1,216 +0,0 @@ -import type { SubjectMessage } from './transport/SubjectInboundTransport' - -import { Subject } from 'rxjs' - -import { - getBaseConfig, - issueCredential, - makeConnection, - prepareForIssuance, - presentProof, - previewFromAttributes, -} from '../packages/core/tests/helpers' - -import { SubjectInboundTransporter } from './transport/SubjectInboundTransport' -import { SubjectOutboundTransporter } from './transport/SubjectOutboundTransport' - -import { - HttpOutboundTransporter, - Agent, - MediationState, - WsOutboundTransporter, - ProofAttributeInfo, - AttributeFilter, - ProofPredicateInfo, - PredicateType, - CredentialState, - ProofState, - AutoAcceptCredential, -} from '@aries-framework/core' -import { HttpInboundTransport, WsInboundTransport } from '@aries-framework/node' - -const recipientConfig = getBaseConfig('E2E Recipient', { - autoAcceptCredentials: AutoAcceptCredential.ContentApproved, -}) -const mediatorConfig = getBaseConfig('E2E Mediator', { - endpoint: 'http://localhost:3002', - autoAcceptMediationRequests: true, -}) -const senderConfig = getBaseConfig('E2E Sender', { - endpoint: 'http://localhost:3003', - mediatorPollingInterval: 1000, - autoAcceptCredentials: AutoAcceptCredential.ContentApproved, -}) - -describe('E2E tests', () => { - let recipientAgent: Agent - let mediatorAgent: Agent - let senderAgent: Agent - - beforeEach(async () => { - recipientAgent = new Agent(recipientConfig.config, recipientConfig.agentDependencies) - mediatorAgent = new Agent(mediatorConfig.config, mediatorConfig.agentDependencies) - senderAgent = new Agent(senderConfig.config, senderConfig.agentDependencies) - }) - - afterEach(async () => { - await recipientAgent.shutdown({ deleteWallet: true }) - await mediatorAgent.shutdown({ deleteWallet: true }) - await senderAgent.shutdown({ deleteWallet: true }) - }) - - test('Full HTTP flow (connect, request mediation, issue, verify)', async () => { - // Recipient Setup - recipientAgent.setOutboundTransporter(new HttpOutboundTransporter()) - await recipientAgent.initialize() - - // Mediator Setup - mediatorAgent.setInboundTransporter(new HttpInboundTransport({ port: 3002 })) - mediatorAgent.setOutboundTransporter(new HttpOutboundTransporter()) - await mediatorAgent.initialize() - - // Sender Setup - senderAgent.setInboundTransporter(new HttpInboundTransport({ port: 3003 })) - senderAgent.setOutboundTransporter(new HttpOutboundTransporter()) - await senderAgent.initialize() - - await e2eTest({ - mediatorAgent, - senderAgent, - recipientAgent, - }) - }) - - test('Full WS flow (connect, request mediation, issue, verify)', async () => { - // Recipient Setup - recipientAgent.setOutboundTransporter(new WsOutboundTransporter()) - await recipientAgent.initialize() - - // Mediator Setup - mediatorAgent.setInboundTransporter(new WsInboundTransport({ port: 3002 })) - mediatorAgent.setOutboundTransporter(new WsOutboundTransporter()) - await mediatorAgent.initialize() - - // Sender Setup - senderAgent.setInboundTransporter(new WsInboundTransport({ port: 3003 })) - senderAgent.setOutboundTransporter(new WsOutboundTransporter()) - await senderAgent.initialize() - - await e2eTest({ - mediatorAgent, - senderAgent, - recipientAgent, - }) - }) - - test('Full Subject flow (connect, request mediation, issue, verify)', async () => { - const mediatorMessages = new Subject() - const recipientMessages = new Subject() - const senderMessages = new Subject() - - const subjectMap = { - 'http://localhost:3002': mediatorMessages, - 'http://localhost:3003': senderMessages, - } - - // Recipient Setup - recipientAgent.setOutboundTransporter(new SubjectOutboundTransporter(recipientMessages, subjectMap)) - recipientAgent.setInboundTransporter(new SubjectInboundTransporter(recipientMessages)) - await recipientAgent.initialize() - - // Mediator Setup - mediatorAgent.setOutboundTransporter(new SubjectOutboundTransporter(mediatorMessages, subjectMap)) - mediatorAgent.setInboundTransporter(new SubjectInboundTransporter(mediatorMessages)) - await mediatorAgent.initialize() - - // Sender Setup - senderAgent.setOutboundTransporter(new SubjectOutboundTransporter(senderMessages, subjectMap)) - senderAgent.setInboundTransporter(new SubjectInboundTransporter(senderMessages)) - await senderAgent.initialize() - - await e2eTest({ - mediatorAgent, - senderAgent, - recipientAgent, - }) - }) -}) - -async function e2eTest({ - mediatorAgent, - recipientAgent, - senderAgent, -}: { - mediatorAgent: Agent - recipientAgent: Agent - senderAgent: Agent -}) { - // Make connection between mediator and recipient - const [mediatorRecipientConnection, recipientMediatorConnection] = await makeConnection(mediatorAgent, recipientAgent) - expect(recipientMediatorConnection).toBeConnectedWith(mediatorRecipientConnection) - - // Request mediation from mediator - const mediationRecord = await recipientAgent.mediationRecipient.requestAndAwaitGrant(recipientMediatorConnection) - expect(mediationRecord.state).toBe(MediationState.Granted) - - // Set mediator as default for recipient, start picking up messages - await recipientAgent.mediationRecipient.setDefaultMediator(mediationRecord) - await recipientAgent.mediationRecipient.initiateMessagePickup(mediationRecord) - const defaultMediator = await recipientAgent.mediationRecipient.findDefaultMediator() - expect(defaultMediator?.id).toBe(mediationRecord.id) - - // Make connection between sender and recipient - const [recipientSenderConnection, senderRecipientConnection] = await makeConnection(recipientAgent, senderAgent) - expect(recipientSenderConnection).toBeConnectedWith(senderRecipientConnection) - - // Issue credential from sender to recipient - const { definition } = await prepareForIssuance(senderAgent, ['name', 'age', 'dateOfBirth']) - const { holderCredential, issuerCredential } = await issueCredential({ - issuerAgent: senderAgent, - holderAgent: recipientAgent, - issuerConnectionId: senderRecipientConnection.id, - credentialTemplate: { - credentialDefinitionId: definition.id, - preview: previewFromAttributes({ - name: 'John', - age: '25', - // year month day - dateOfBirth: '19950725', - }), - }, - }) - - expect(holderCredential.state).toBe(CredentialState.Done) - expect(issuerCredential.state).toBe(CredentialState.Done) - - // Present Proof from recipient to sender - const definitionRestriction = [ - new AttributeFilter({ - credentialDefinitionId: definition.id, - }), - ] - const { holderProof, verifierProof } = await presentProof({ - verifierAgent: senderAgent, - holderAgent: recipientAgent, - verifierConnectionId: senderRecipientConnection.id, - presentationTemplate: { - attributes: { - name: new ProofAttributeInfo({ - name: 'name', - restrictions: definitionRestriction, - }), - }, - predicates: { - olderThan21: new ProofPredicateInfo({ - name: 'age', - restrictions: definitionRestriction, - predicateType: PredicateType.LessThan, - predicateValue: 20000712, - }), - }, - }, - }) - - expect(holderProof.state).toBe(ProofState.Done) - expect(verifierProof.state).toBe(ProofState.Done) -} diff --git a/tests/transport/SubjectInboundTransport.ts b/tests/transport/SubjectInboundTransport.ts index 1482bc6b26..087064f099 100644 --- a/tests/transport/SubjectInboundTransport.ts +++ b/tests/transport/SubjectInboundTransport.ts @@ -1,12 +1,12 @@ import type { InboundTransporter, Agent } from '../../packages/core/src' import type { TransportSession } from '../../packages/core/src/agent/TransportService' -import type { OutboundPackage, WireMessage } from '../../packages/core/src/types' +import type { WireMessage } from '../../packages/core/src/types' import type { Subject, Subscription } from 'rxjs' import { AgentConfig } from '../../packages/core/src/agent/AgentConfig' import { uuid } from '../../packages/core/src/utils/uuid' -export type SubjectMessage = { message: WireMessage; replySubject?: Subject } +export type SubjectMessage = { message: WireMessage; replySubject?: Subject } export class SubjectInboundTransporter implements InboundTransporter { private subject: Subject @@ -52,7 +52,7 @@ export class SubjectTransportSession implements TransportSession { this.replySubject = replySubject } - public async send(outboundMessage: OutboundPackage): Promise { - this.replySubject.next({ message: outboundMessage.payload }) + public async send(wireMessage: WireMessage): Promise { + this.replySubject.next({ message: wireMessage }) } } diff --git a/tests/transport/SubjectOutboundTransport.ts b/tests/transport/SubjectOutboundTransport.ts index a18015f6ab..fc2094fc27 100644 --- a/tests/transport/SubjectOutboundTransport.ts +++ b/tests/transport/SubjectOutboundTransport.ts @@ -30,12 +30,9 @@ export class SubjectOutboundTransporter implements OutboundTransporter { } public async sendMessage(outboundPackage: OutboundPackage) { - this.logger.debug( - `Sending outbound message to connection ${outboundPackage.connection.id} (${outboundPackage.connection.theirLabel})`, - { - endpoint: outboundPackage.endpoint, - } - ) + this.logger.debug(`Sending outbound message to endpoint ${outboundPackage.endpoint}`, { + endpoint: outboundPackage.endpoint, + }) const { payload, endpoint } = outboundPackage if (!endpoint) { diff --git a/tsconfig.build.json b/tsconfig.build.json index cc2514c07b..3ff691c0e8 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -14,5 +14,5 @@ "experimentalDecorators": true, "emitDecoratorMetadata": true }, - "exclude": ["node_modules", "build", "**/*.test.ts", "**/__tests__/*.ts", "**/__mocks__/*.ts"] + "exclude": ["node_modules", "build", "**/*.test.ts", "**/__tests__/*.ts", "**/__mocks__/*.ts", "**/build/**"] } diff --git a/tsconfig.test.json b/tsconfig.test.json index e5372ecc91..8d82b6910a 100644 --- a/tsconfig.test.json +++ b/tsconfig.test.json @@ -10,5 +10,5 @@ }, "types": ["jest", "node"] }, - "include": ["tests", "samples"] + "include": ["tests", "samples", "packages/core/types/jest.d.ts"] }