Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add problem report protocol #560

Merged
merged 9 commits into from
Dec 13, 2021
120 changes: 83 additions & 37 deletions packages/core/src/agent/MessageReceiver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,21 +9,28 @@ import { Lifecycle, scoped } from 'tsyringe'

import { AriesFrameworkError } from '../error'
import { ConnectionService } from '../modules/connections/services/ConnectionService'
import { ProblemReportError, ProblemReportMessage } from '../modules/problem-reports'
import { JsonTransformer } from '../utils/JsonTransformer'
import { MessageValidator } from '../utils/MessageValidator'
import { replaceLegacyDidSovPrefixOnMessage } from '../utils/messageType'

import { AgentConfig } from './AgentConfig'
import { Dispatcher } from './Dispatcher'
import { EnvelopeService } from './EnvelopeService'
import { MessageSender } from './MessageSender'
import { TransportService } from './TransportService'
import { createOutboundMessage, isOutboundServiceMessage } from './helpers'
import { InboundMessageContext } from './models/InboundMessageContext'

export enum ProblemReportReason {
MessageParseFailure = 'message-parse-failure',
TimoGlastra marked this conversation as resolved.
Show resolved Hide resolved
}
@scoped(Lifecycle.ContainerScoped)
export class MessageReceiver {
private config: AgentConfig
private envelopeService: EnvelopeService
private transportService: TransportService
private messageSender: MessageSender
private connectionService: ConnectionService
private dispatcher: Dispatcher
private logger: Logger
Expand All @@ -33,12 +40,14 @@ export class MessageReceiver {
config: AgentConfig,
envelopeService: EnvelopeService,
transportService: TransportService,
messageSender: MessageSender,
connectionService: ConnectionService,
dispatcher: Dispatcher
) {
this.config = config
this.envelopeService = envelopeService
this.transportService = transportService
this.messageSender = messageSender
this.connectionService = connectionService
this.dispatcher = dispatcher
this.logger = this.config.logger
Expand Down Expand Up @@ -84,47 +93,54 @@ export class MessageReceiver {
unpackedMessage.message
)

const message = await this.transformMessage(unpackedMessage)
try {
await MessageValidator.validate(message)
} catch (error) {
this.logger.error(`Error validating message ${message.type}`, {
errors: error,
message: message.toJSON(),
})

throw error
}
const message = await this.transformMessage(unpackedMessage)
try {
await MessageValidator.validate(message)
} catch (error) {
this.logger.error(`Error validating message ${unpackedMessage.message['@type']}`, {
errors: error,
message: message.toJSON(),
})
this.sendProblemReportMessage(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
this.sendProblemReportMessage(
await this.sendProblemReportMessage(

`Error validating message ${unpackedMessage.message['@type']}`,
connection!,
unpackedMessage
)
}
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Maybe we can extract this to a separate method (like transformMessage) so we don't have to nest try/catch statements


const messageContext = new InboundMessageContext(message, {
// Only make the connection available in message context if the connection is ready
// To prevent unwanted usage of unready connections. Connections can still be retrieved from
// Storage if the specific protocol allows an unready connection to be used.
connection: connection?.isReady ? connection : undefined,
senderVerkey: senderKey,
recipientVerkey: recipientKey,
})
const messageContext = new InboundMessageContext(message, {
// Only make the connection available in message context if the connection is ready
// To prevent unwanted usage of unready connections. Connections can still be retrieved from
// Storage if the specific protocol allows an unready connection to be used.
connection: connection?.isReady ? connection : undefined,
senderVerkey: senderKey,
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 (senderKey && recipientKey && message.hasAnyReturnRoute() && session) {
this.logger.debug(`Storing session for inbound message '${message.id}'`)
const keys = {
recipientKeys: [senderKey],
routingKeys: [],
senderKey: 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 (senderKey && recipientKey && message.hasAnyReturnRoute() && session) {
this.logger.debug(`Storing session for inbound message '${message.id}'`)
const keys = {
recipientKeys: [senderKey],
routingKeys: [],
senderKey: recipientKey,
}
session.keys = keys
session.inboundMessage = message
// We allow unready connections to be attached to the session as we want to be able to
// use return routing to make connections. This is especially useful for creating connections
// with mediators when you don't have a public endpoint yet.
session.connection = connection ?? undefined
this.transportService.saveSession(session)
}
session.keys = keys
session.inboundMessage = message
// We allow unready connections to be attached to the session as we want to be able to
// use return routing to make connections. This is especially useful for creating connections
// with mediators when you don't have a public endpoint yet.
session.connection = connection ?? undefined
this.transportService.saveSession(session)
}

await this.dispatcher.dispatch(messageContext)
await this.dispatcher.dispatch(messageContext)
} catch (error) {
this.sendProblemReportMessage(error.message, connection!, unpackedMessage)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should try to avoid non-null assertions. If we don't have a connection, we can't send the message so we shouldn't ignore this I think

}
}

/**
Expand Down Expand Up @@ -174,12 +190,42 @@ export class MessageReceiver {
const MessageClass = this.dispatcher.getMessageClassForType(messageType)

if (!MessageClass) {
throw new AriesFrameworkError(`No message class found for message type "${messageType}"`)
// throw new AriesFrameworkError(`No message class found for message type "${messageType}"`)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
// throw new AriesFrameworkError(`No message class found for message type "${messageType}"`)

throw new ProblemReportError(`No message class found for message type "${messageType}"`, {
problemCode: ProblemReportReason.MessageParseFailure,
})
}

// Cast the plain JSON object to specific instance of Message extended from AgentMessage
const message = JsonTransformer.fromJSON(unpackedMessage.message, MessageClass)

return message
}

private async sendProblemReportMessage(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should add an check here whether the received message is of @type == https://didcomm.org/notification/1.0/problem-reportand otherwise throw an error (not a problem report error) that we are not sending a problem report as response to a problem. to prevent accidential problem report loops from happening. We should probably never respond with a problem report to a problem report.

message: string,
connection: ConnectionRecord,
unpackedMessage: UnpackedMessageContext
) {
const problemReportMessage = new ProblemReportMessage({
description: {
en: message,
code: ProblemReportReason.MessageParseFailure,
},
})
problemReportMessage.setThread({
threadId: unpackedMessage.message['@id'],
})
const outboundMessage = createOutboundMessage(connection!, problemReportMessage)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, avoid non-null assertions

if (outboundMessage && isOutboundServiceMessage(outboundMessage)) {
await this.messageSender.sendMessageToService({
message: outboundMessage.payload,
service: outboundMessage.service,
senderKey: outboundMessage.senderKey,
returnRoute: true,
})
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

If connection is provided (which is always the case in this method) this will never be true, as it used for connectionless exchanges.

you can call messageSender.sendMessage directly

} else if (outboundMessage) {
await this.messageSender.sendMessage(outboundMessage)
}
}
}
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
import type { Wallet } from '../../wallet/Wallet'

import { AriesFrameworkError } from '../../error'
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unused imoprt

import { ConnectionProblemReportError, ConnectionProblemReportReason } from '../../modules/connections/errors'
import { BufferEncoder } from '../../utils/BufferEncoder'
import { JsonEncoder } from '../../utils/JsonEncoder'
import { Buffer } from '../../utils/buffer'
Expand Down Expand Up @@ -29,7 +30,10 @@ export async function unpackAndVerifySignatureDecorator(
const isValid = await wallet.verify(signerVerkey, signedData, signature)

if (!isValid) {
throw new AriesFrameworkError('Signature is not valid!')
throw new ConnectionProblemReportError(
'Signature is not valid!',
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
'Signature is not valid!',
'Signature is not valid',

ConnectionProblemReportReason.RequestProcessingError
)
}

// TODO: return Connection instance instead of raw json
Expand Down
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
export enum CommonMessageType {
Ack = 'https://didcomm.org/notification/1.0/ack',
ProblemReport = 'https://didcomm.org/notification/1.0/problem-report',
TimoGlastra marked this conversation as resolved.
Show resolved Hide resolved
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ConnectionProblemReportReason } from './ConnectionProblemReportReason'

import { AriesFrameworkError } from '../../../error/AriesFrameworkError'

export class ConnectionProblemReportError extends AriesFrameworkError {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we should create a generic ProblemReportError where the ConnectionProblemReportError can extend from. This way we can check just for instanceof ProblemReportError in the dispatcher. As the ConectionProblemReportMessage extends the ProblemReportMessage it is also possible to use the connection specific problem report message for the problemReport property

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't extend problem report error yet

public constructor(public message: string, public problemCode: ConnectionProblemReportReason) {
super(message)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
/**
* Connection error code in RFC 160.
*
* @see https://github.com/hyperledger/aries-rfcs/blob/main/features/0160-connection-protocol/README.md#errors
*/
export enum ConnectionProblemReportReason {
RequestNotAccepted = 'request_not_accepted',
RequestProcessingError = 'request_processing_error',
ResponseNotAccepted = 'response_not_accepted',
ResponseProcessingError = 'response_processing_error',
}
2 changes: 2 additions & 0 deletions packages/core/src/modules/connections/errors/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
export * from './ConnectionProblemReportError'
export * from './ConnectionProblemReportReason'
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,8 @@ import type { ConnectionService, Routing } from '../services/ConnectionService'

import { createOutboundMessage } from '../../../agent/helpers'
import { AriesFrameworkError } from '../../../error/AriesFrameworkError'
import { ConnectionRequestMessage } from '../messages'
import { ConnectionProblemReportError } from '../errors'
import { ConnectionProblemReportMessage, ConnectionRequestMessage } from '../messages'

export class ConnectionRequestHandler implements Handler {
private connectionService: ConnectionService
Expand Down Expand Up @@ -42,11 +43,26 @@ export class ConnectionRequestHandler implements Handler {
routing = await this.mediationRecipientService.getRouting(mediationRecord)
}

connectionRecord = await this.connectionService.processRequest(messageContext, routing)
try {
connectionRecord = await this.connectionService.processRequest(messageContext, routing)

if (connectionRecord?.autoAcceptConnection ?? this.agentConfig.autoAcceptConnections) {
const { message } = await this.connectionService.createResponse(connectionRecord.id)
return createOutboundMessage(connectionRecord, message)
if (connectionRecord?.autoAcceptConnection ?? this.agentConfig.autoAcceptConnections) {
const { message } = await this.connectionService.createResponse(connectionRecord.id)
return createOutboundMessage(connectionRecord, message)
}
} catch (error) {
if (error instanceof ConnectionProblemReportError) {
const connectionProblemReportMessage = new ConnectionProblemReportMessage({
description: {
en: error.message,
code: error.problemCode,
},
})
connectionProblemReportMessage.setThread({
threadId: messageContext.message.threadId,
})
return createOutboundMessage(messageContext.connection!, connectionProblemReportMessage)
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think we could abstract the processing of problem report messages by adding the problem report message inside the error class. this way we can handle it once in the dispatcher class: https://hackmd.io/@animo/BkEzghBPY

}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,8 @@ import type { Handler, HandlerInboundMessage } from '../../../agent/Handler'
import type { ConnectionService } from '../services/ConnectionService'

import { createOutboundMessage } from '../../../agent/helpers'
import { ConnectionResponseMessage } from '../messages'
import { ConnectionProblemReportError } from '../errors'
import { ConnectionProblemReportMessage, ConnectionResponseMessage } from '../messages'

export class ConnectionResponseHandler implements Handler {
private connectionService: ConnectionService
Expand All @@ -16,14 +17,29 @@ export class ConnectionResponseHandler implements Handler {
}

public async handle(messageContext: HandlerInboundMessage<ConnectionResponseHandler>) {
const connection = await this.connectionService.processResponse(messageContext)
try {
const connection = await this.connectionService.processResponse(messageContext)

// TODO: should we only send ping message in case of autoAcceptConnection or always?
// In AATH we have a separate step to send the ping. So for now we'll only do it
// if auto accept is enable
if (connection.autoAcceptConnection ?? this.agentConfig.autoAcceptConnections) {
const { message } = await this.connectionService.createTrustPing(connection.id, { responseRequested: false })
return createOutboundMessage(connection, message)
// TODO: should we only send ping message in case of autoAcceptConnection or always?
// In AATH we have a separate step to send the ping. So for now we'll only do it
// if auto accept is enable
if (connection.autoAcceptConnection ?? this.agentConfig.autoAcceptConnections) {
const { message } = await this.connectionService.createTrustPing(connection.id, { responseRequested: false })
return createOutboundMessage(connection, message)
}
} catch (error) {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Same here, we then wouldn't need a try/catch in every handler class

if (error instanceof ConnectionProblemReportError) {
const connectionProblemReportMessage = new ConnectionProblemReportMessage({
description: {
en: error.message,
code: error.problemCode,
},
})
connectionProblemReportMessage.setThread({
threadId: messageContext.message.threadId,
})
return createOutboundMessage(messageContext.connection!, connectionProblemReportMessage)
}
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import type { ProblemReportMessageOptions } from '../../problem-reports/messages/ProblemReportMessage'

import { Equals } from 'class-validator'

import { ProblemReportMessage } from '../../problem-reports/messages/ProblemReportMessage'

export type ConnectionProblemReportMessageOptions = ProblemReportMessageOptions

/**
* @see https://github.com/hyperledger/aries-rfcs/blob/main/features/0035-report-problem/README.md
*/
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I can't find the connection problem report handler.

export class ConnectionProblemReportMessage extends ProblemReportMessage {
/**
* Create new ConnectionProblemReportMessage instance.
* @param options
*/
public constructor(options: ConnectionProblemReportMessageOptions) {
super(options)
}

@Equals(ConnectionProblemReportMessage.type)
public readonly type = ConnectionProblemReportMessage.type
public static readonly type = 'https://didcomm.org/connection/1.0/problem-report'
}
1 change: 1 addition & 0 deletions packages/core/src/modules/connections/messages/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@ export * from './ConnectionRequestMessage'
export * from './ConnectionResponseMessage'
export * from './TrustPingMessage'
export * from './TrustPingResponseMessage'
export * from './ConnectionProblemReportMessage'
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import { JsonTransformer } from '../../../utils/JsonTransformer'
import { MessageValidator } from '../../../utils/MessageValidator'
import { Wallet } from '../../../wallet/Wallet'
import { ConnectionEventTypes } from '../ConnectionEvents'
import { ConnectionProblemReportError, ConnectionProblemReportReason } from '../errors'
import {
ConnectionInvitationMessage,
ConnectionRequestMessage,
Expand Down Expand Up @@ -81,7 +82,6 @@ export class ConnectionService {
autoAcceptConnection: config?.autoAcceptConnection,
multiUseInvitation: config.multiUseInvitation ?? false,
})

const { didDoc } = connectionRecord
const [service] = didDoc.didCommServices
const invitation = new ConnectionInvitationMessage({
Expand Down Expand Up @@ -213,14 +213,18 @@ export class ConnectionService {
connectionRecord.assertRole(ConnectionRole.Inviter)

if (!message.connection.didDoc) {
throw new AriesFrameworkError('Public DIDs are not supported yet')
throw new ConnectionProblemReportError(
'Public DIDs are not supported yet',
ConnectionProblemReportReason.RequestNotAccepted
)
}

// Create new connection if using a multi use invitation
if (connectionRecord.multiUseInvitation) {
if (!routing) {
throw new AriesFrameworkError(
'Cannot process request for multi-use invitation without routing object. Make sure to call processRequest with the routing parameter provided.'
throw new ConnectionProblemReportError(
'Cannot process request for multi-use invitation without routing object. Make sure to call processRequest with the routing parameter provided.',
ConnectionProblemReportReason.RequestNotAccepted
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a framework error so I think we shouldn't send a problem report for it. Or at least not with this message and this reason

)
}

Expand Down Expand Up @@ -330,8 +334,9 @@ export class ConnectionService {
const signerVerkey = message.connectionSig.signer
const invitationKey = connectionRecord.getTags().invitationKey
if (signerVerkey !== invitationKey) {
throw new AriesFrameworkError(
`Connection object in connection response message is not signed with same key as recipient key in invitation expected='${invitationKey}' received='${signerVerkey}'`
throw new ConnectionProblemReportError(
`Connection object in connection response message is not signed with same key as recipient key in invitation expected='${invitationKey}' received='${signerVerkey}'`,
ConnectionProblemReportReason.ResponseNotAccepted
)
}

Expand Down
Loading