Skip to content

Commit

Permalink
fix(mrtd): return disclosures for sync requests (#66)
Browse files Browse the repository at this point in the history
Signed-off-by: Ariel Gentile <[email protected]>
  • Loading branch information
genaris authored Dec 6, 2024
1 parent 7d66477 commit 9095cd7
Show file tree
Hide file tree
Showing 7 changed files with 383 additions and 4 deletions.
2 changes: 2 additions & 0 deletions packages/mrtd/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -43,6 +43,8 @@
"class-validator": "0.14.1",
"esbuild": "^0.24.0",
"mrz": "^4.2.0",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.2.0",
"tsyringe": "^4.8.0"
}
}
27 changes: 23 additions & 4 deletions packages/mrtd/src/DidCommMrtdApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import {
MessageSender,
FeatureRegistry,
DiscoverFeaturesApi,
Feature,
} from '@credo-ts/core'

import { DidCommMrtdService } from './DidCommMrtdService'
Expand All @@ -17,6 +18,7 @@ import {
MrzDataRequestHandler,
} from './handlers'
import { Capability } from './models/Capability'
import { MrtdCapabilities } from './models/MrtdCapabilities'
import { MrtdProblemReportReason } from './models/ProblemReportReason'

@injectable()
Expand Down Expand Up @@ -139,9 +141,20 @@ export class DidCommMrtdApi {
* a DIDComm connection queries using Discover Fatures protocol
* @param options: eMrtdReadSupported: boolean indicating if the device supports eMRTD reading
*/
public async setEMrtdCapabilities(options: { eMrtdReadSupported: boolean }) {
public async setMrtdCapabilities(options: { eMrtdReadSupported: boolean }) {
const featureRegistry = this.agentContext.dependencyManager.resolve(FeatureRegistry)
featureRegistry.register(new Capability({ id: 'mrtd.emrtd-read-support', value: options.eMrtdReadSupported }))

// TODO: This is a hack to allow features to be overwritten. This should be fixed in Credo
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const features = (featureRegistry as any).features as Feature[]

const existingItemIndex = features.findIndex((item) => item.id === MrtdCapabilities.EMrtdReadSupport)
if (existingItemIndex !== -1) {
features.splice(existingItemIndex, 1)
}
featureRegistry.register(
new Capability({ id: MrtdCapabilities.EMrtdReadSupport, value: options.eMrtdReadSupported }),
)
}

/**
Expand All @@ -151,20 +164,26 @@ export class DidCommMrtdApi {
* Discover Features Disclosure events
* @param connectionId
*/
public async requestEMrtdCapabilities(options: {
public async requestMrtdCapabilities(options: {
connectionId: string
awaitDisclosure?: boolean
awaitDisclosureTimeoutMs?: number
}) {
const { connectionId, awaitDisclosure, awaitDisclosureTimeoutMs } = options
const discoverFeatures = this.agentContext.dependencyManager.resolve(DiscoverFeaturesApi)

discoverFeatures.queryFeatures({
const disclosures = await discoverFeatures.queryFeatures({
connectionId,
protocolVersion: 'v2',
queries: [{ featureType: 'capability', match: 'mrtd.*' }],
awaitDisclosures: awaitDisclosure,
awaitDisclosuresTimeoutMs: awaitDisclosureTimeoutMs,
})

return {
eMrtdReadSupported: disclosures.features?.some(
(feature) => feature.id === MrtdCapabilities.EMrtdReadSupport && (feature as Capability).value === true,
),
}
}
}
139 changes: 139 additions & 0 deletions packages/mrtd/tests/mrtd.e2e.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,139 @@
import type { ConnectionRecord, EncryptedMessage } from '@credo-ts/core'

import { AskarModule } from '@credo-ts/askar'
import { Agent, ConsoleLogger, DidExchangeState, LogLevel, utils } from '@credo-ts/core'
import { agentDependencies } from '@credo-ts/node'
import { ariesAskar } from '@hyperledger/aries-askar-nodejs'
import { Subject } from 'rxjs'

import { DidCommMrtdModule } from '../src/DidCommMrtdModule'

import { SubjectInboundTransport } from './transport/SubjectInboundTransport'
import { SubjectOutboundTransport } from './transport/SubjectOutboundTransport'

const logger = new ConsoleLogger(LogLevel.off)

export type SubjectMessage = { message: EncryptedMessage; replySubject?: Subject<SubjectMessage> }

describe('profile test', () => {
let aliceAgent: Agent<{ askar: AskarModule; mrtd: DidCommMrtdModule }>
let bobAgent: Agent<{ askar: AskarModule; mrtd: DidCommMrtdModule }>
let aliceWalletId: string
let aliceWalletKey: string
let bobWalletId: string
let bobWalletKey: string
let aliceConnectionRecord: ConnectionRecord
let bobConnectionRecord: ConnectionRecord

beforeEach(async () => {
aliceWalletId = utils.uuid()
aliceWalletKey = utils.uuid()
bobWalletId = utils.uuid()
bobWalletKey = utils.uuid()

const aliceMessages = new Subject<SubjectMessage>()
const bobMessages = new Subject<SubjectMessage>()

const subjectMap = {
'rxjs:alice': aliceMessages,
'rxjs:bob': bobMessages,
}

// Initialize alice
aliceAgent = new Agent({
config: {
label: 'alice',
endpoints: ['rxjs:alice'],
walletConfig: { id: aliceWalletId, key: aliceWalletKey },
logger,
},
dependencies: agentDependencies,
modules: { askar: new AskarModule({ ariesAskar }), mrtd: new DidCommMrtdModule() },
})

aliceAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap))
aliceAgent.registerInboundTransport(new SubjectInboundTransport(aliceMessages))
await aliceAgent.initialize()

// Initialize bob
bobAgent = new Agent({
config: {
endpoints: ['rxjs:bob'],
label: 'bob',
walletConfig: { id: bobWalletId, key: bobWalletKey },
logger,
},
dependencies: agentDependencies,
modules: { askar: new AskarModule({ ariesAskar }), mrtd: new DidCommMrtdModule() },
})

bobAgent.registerOutboundTransport(new SubjectOutboundTransport(subjectMap))
bobAgent.registerInboundTransport(new SubjectInboundTransport(bobMessages))
await bobAgent.initialize()

const outOfBandRecord = await aliceAgent.oob.createInvitation({
autoAcceptConnection: true,
})

const { connectionRecord } = await bobAgent.oob.receiveInvitationFromUrl(
outOfBandRecord.outOfBandInvitation.toUrl({ domain: 'https://example.com/ssi' }),
{ autoAcceptConnection: true },
)

bobConnectionRecord = await bobAgent.connections.returnWhenIsConnected(connectionRecord!.id)
expect(bobConnectionRecord.state).toBe(DidExchangeState.Completed)

aliceConnectionRecord = (await aliceAgent.connections.findAllByOutOfBandId(outOfBandRecord.id))[0]
aliceConnectionRecord = await aliceAgent.connections.returnWhenIsConnected(aliceConnectionRecord!.id)
expect(aliceConnectionRecord.state).toBe(DidExchangeState.Completed)
})

afterEach(async () => {
// Wait for messages to flush out
await new Promise((r) => setTimeout(r, 1000))

if (aliceAgent) {
await aliceAgent.shutdown()

if (aliceAgent.wallet.isInitialized && aliceAgent.wallet.isProvisioned) {
await aliceAgent.wallet.delete()
}
}

if (bobAgent) {
await bobAgent.shutdown()

if (bobAgent.wallet.isInitialized && bobAgent.wallet.isProvisioned) {
await bobAgent.wallet.delete()
}
}
})

test('Set and query an NFC support', async () => {
// Bob requests MRTD capabilities. eMRTD read support is false, since it is not set
let response = await bobAgent.modules.mrtd.requestMrtdCapabilities({
connectionId: bobConnectionRecord.id,
awaitDisclosure: true,
})
expect(response.eMrtdReadSupported).toBeFalsy()

// Alice sets eMRTD read support. When Bob queries for it, he should get a true value
await aliceAgent.modules.mrtd.setMrtdCapabilities({ eMrtdReadSupported: true })

response = await bobAgent.modules.mrtd.requestMrtdCapabilities({
connectionId: bobConnectionRecord.id,
awaitDisclosure: true,
})
expect(response.eMrtdReadSupported).toBeTruthy()

// Alice now sets eMRTD read support to false. When Bob queries for it, he should get a false value

await aliceAgent.modules.mrtd.setMrtdCapabilities({ eMrtdReadSupported: false })

response = await bobAgent.modules.mrtd.requestMrtdCapabilities({
connectionId: bobConnectionRecord.id,
awaitDisclosure: true,
})
expect(response.eMrtdReadSupported).toBeFalsy()
})
})
87 changes: 87 additions & 0 deletions packages/mrtd/tests/recordUtils.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
import type {
BaseRecord,
RecordSavedEvent,
RecordDeletedEvent,
RecordUpdatedEvent,
Agent,
BaseEvent,
} from '@credo-ts/core'
import type { Constructor } from '@credo-ts/core/build/utils/mixins'

import { RepositoryEventTypes } from '@credo-ts/core'
import { map, filter, pipe } from 'rxjs'

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type BaseRecordAny = BaseRecord<any, any, any>
type RecordClass<R extends BaseRecordAny> = Constructor<R> & { type: string }
export interface RecordsState<R extends BaseRecordAny> {
loading: boolean
records: R[]
}

export const addRecord = <R extends BaseRecordAny>(record: R, state: RecordsState<R>): RecordsState<R> => {
const newRecordsState = [...state.records]
newRecordsState.unshift(record)
return {
loading: state.loading,
records: newRecordsState,
}
}

export const updateRecord = <R extends BaseRecordAny>(record: R, state: RecordsState<R>): RecordsState<R> => {
const newRecordsState = [...state.records]
const index = newRecordsState.findIndex((r) => r.id === record.id)
if (index > -1) {
newRecordsState[index] = record
}
return {
loading: state.loading,
records: newRecordsState,
}
}

export const removeRecord = <R extends BaseRecordAny>(record: R, state: RecordsState<R>): RecordsState<R> => {
const newRecordsState = state.records.filter((r) => r.id !== record.id)
return {
loading: state.loading,
records: newRecordsState,
}
}

const filterByType = <R extends BaseRecordAny>(recordClass: RecordClass<R>) => {
return pipe(
map((event: BaseEvent) => (event.payload as Record<string, R>).record),
filter((record: R) => record.type === recordClass.type),
)
}

export const recordsAddedByType = <R extends BaseRecordAny>(agent: Agent | undefined, recordClass: RecordClass<R>) => {
if (!agent) {
throw new Error('Agent is required to subscribe to events')
}
return agent?.events.observable<RecordSavedEvent<R>>(RepositoryEventTypes.RecordSaved).pipe(filterByType(recordClass))
}

export const recordsUpdatedByType = <R extends BaseRecordAny>(
agent: Agent | undefined,
recordClass: RecordClass<R>,
) => {
if (!agent) {
throw new Error('Agent is required to subscribe to events')
}
return agent?.events
.observable<RecordUpdatedEvent<R>>(RepositoryEventTypes.RecordUpdated)
.pipe(filterByType(recordClass))
}

export const recordsRemovedByType = <R extends BaseRecordAny>(
agent: Agent | undefined,
recordClass: RecordClass<R>,
) => {
if (!agent) {
throw new Error('Agent is required to subscribe to events')
}
return agent?.events
.observable<RecordDeletedEvent<R>>(RepositoryEventTypes.RecordDeleted)
.pipe(filterByType(recordClass))
}
1 change: 1 addition & 0 deletions packages/mrtd/tests/setup.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
import 'reflect-metadata'
69 changes: 69 additions & 0 deletions packages/mrtd/tests/transport/SubjectInboundTransport.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,69 @@
import type { Agent, AgentContext, EncryptedMessage, InboundTransport, TransportSession } from '@credo-ts/core'
import type { Subscription } from 'rxjs'

import { MessageReceiver, TransportService, utils } from '@credo-ts/core'
import { Subject } from 'rxjs'

export type SubjectMessage = { message: EncryptedMessage; replySubject?: Subject<SubjectMessage> }

export class SubjectInboundTransport implements InboundTransport {
public readonly ourSubject: Subject<SubjectMessage>
private subscription?: Subscription

public constructor(ourSubject = new Subject<SubjectMessage>()) {
this.ourSubject = ourSubject
}

public async start(agent: Agent) {
this.subscribe(agent)
}

public async stop() {
this.subscription?.unsubscribe()
}

private subscribe(agent: Agent) {
const logger = agent.config.logger
const transportService = agent.dependencyManager.resolve(TransportService)
const messageReceiver = agent.dependencyManager.resolve(MessageReceiver)

this.subscription = this.ourSubject.subscribe({
next: async ({ message, replySubject }: SubjectMessage) => {
logger.test('Received message')

let session: SubjectTransportSession | undefined
if (replySubject) {
session = new SubjectTransportSession(`subject-session-${utils.uuid()}`, replySubject)

// When the subject is completed (e.g. when the session is closed), we need to
// remove the session from the transport service so it won't be used for sending messages
// in the future.
replySubject.subscribe({
complete: () => session && transportService.removeSession(session),
})
}

await messageReceiver.receiveMessage(message, { session })
},
})
}
}

export class SubjectTransportSession implements TransportSession {
public id: string
public readonly type = 'subject'
private replySubject: Subject<SubjectMessage>

public constructor(id: string, replySubject: Subject<SubjectMessage>) {
this.id = id
this.replySubject = replySubject
}

public async send(agentContext: AgentContext, encryptedMessage: EncryptedMessage): Promise<void> {
this.replySubject.next({ message: encryptedMessage })
}

public async close(): Promise<void> {
this.replySubject.complete()
}
}
Loading

0 comments on commit 9095cd7

Please sign in to comment.