diff --git a/jest.config.js b/jest.config.js index 467f749594..57c348ed92 100644 --- a/jest.config.js +++ b/jest.config.js @@ -13,5 +13,5 @@ module.exports = { }, collectCoverageFrom: ['src/**/*.{js,jsx,tsx,ts}'], coveragePathIgnorePatterns: ['/node_modules/', '/__tests__/'], - testTimeout: 60000, + testTimeout: 120000, } diff --git a/src/__tests__/credentials-auto-accept.test.ts b/src/__tests__/credentials-auto-accept.test.ts new file mode 100644 index 0000000000..98db4ff9bd --- /dev/null +++ b/src/__tests__/credentials-auto-accept.test.ts @@ -0,0 +1,497 @@ +import type { Agent } from '../agent/Agent' +import type { ConnectionRecord } from '../modules/connections' + +import { + AutoAcceptCredential, + CredentialPreview, + CredentialPreviewAttribute, + CredentialRecord, + CredentialState, +} from '../modules/credentials' +import { JsonTransformer } from '../utils/JsonTransformer' +import { sleep } from '../utils/sleep' + +import { 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 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', + }), + ], +}) + +describe('auto accept credentials', () => { + let faberAgent: Agent + let aliceAgent: Agent + let credDefId: string + let schemaId: string + let faberConnection: ConnectionRecord + let aliceConnection: ConnectionRecord + let faberCredentialRecord: CredentialRecord + let aliceCredentialRecord: CredentialRecord + + describe('Auto accept on `always`', () => { + beforeAll(async () => { + ;({ faberAgent, aliceAgent, credDefId, schemaId, faberConnection, aliceConnection } = await setupCredentialTests( + 'faber agent always', + 'alice agent always', + AutoAcceptCredential.Always + )) + }) + + afterAll(async () => { + await aliceAgent.shutdown({ + deleteWallet: true, + }) + await faberAgent.shutdown({ + deleteWallet: true, + }) + }) + + test('Alice starts with credential proposal to Faber, both with autoAcceptCredential on `always`', async () => { + testLogger.test('Alice sends credential proposal to Faber') + let aliceCredentialRecord = await aliceAgent.credentials.proposeCredential(aliceConnection.id, { + credentialProposal: credentialPreview, + credentialDefinitionId: credDefId, + }) + + testLogger.test('Alice waits for credential from Faber') + aliceCredentialRecord = await waitForCredentialRecord(aliceAgent, { + threadId: aliceCredentialRecord.threadId, + state: CredentialState.CredentialReceived, + }) + + testLogger.test('Faber waits for credential ack from Alice') + faberCredentialRecord = await waitForCredentialRecord(faberAgent, { + threadId: aliceCredentialRecord.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), + schemaId, + credentialDefinitionId: credDefId, + }, + credentialId: expect.any(String), + state: CredentialState.Done, + }) + + expect(faberCredentialRecord).toMatchObject({ + type: CredentialRecord.name, + id: expect.any(String), + createdAt: expect.any(Date), + metadata: { + schemaId, + credentialDefinitionId: credDefId, + }, + offerMessage: expect.any(Object), + requestMessage: expect.any(Object), + state: CredentialState.Done, + }) + }) + + test('Faber starts with credential offer to Alice, both with autoAcceptCredential on `always`', async () => { + testLogger.test('Faber sends credential offer to Alice') + faberCredentialRecord = await faberAgent.credentials.offerCredential(faberConnection.id, { + preview: credentialPreview, + credentialDefinitionId: credDefId, + comment: 'some comment about credential', + }) + + testLogger.test('Alice waits for credential from Faber') + aliceCredentialRecord = await waitForCredentialRecord(aliceAgent, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.CredentialReceived, + }) + + testLogger.test('Faber waits for credential ack from Alice') + faberCredentialRecord = await waitForCredentialRecord(faberAgent, { + 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, + }) + + 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, + }) + }) + }) + + describe('Auto accept on `contentApproved`', () => { + beforeAll(async () => { + ;({ faberAgent, aliceAgent, credDefId, schemaId, faberConnection, aliceConnection } = await setupCredentialTests( + 'faber agent contentApproved', + 'alice agent contentApproved', + AutoAcceptCredential.ContentApproved + )) + }) + + afterAll(async () => { + await aliceAgent.shutdown({ + deleteWallet: true, + }) + await faberAgent.shutdown({ + deleteWallet: true, + }) + }) + + test('Alice starts with credential proposal to Faber, both with autoAcceptCredential on `contentApproved`', async () => { + testLogger.test('Alice sends credential proposal to Faber') + let aliceCredentialRecord = await aliceAgent.credentials.proposeCredential(aliceConnection.id, { + credentialProposal: credentialPreview, + credentialDefinitionId: credDefId, + }) + + testLogger.test('Faber waits for credential proposal from Alice') + let faberCredentialRecord = await waitForCredentialRecord(faberAgent, { + threadId: aliceCredentialRecord.threadId, + state: CredentialState.ProposalReceived, + }) + + testLogger.test('Faber sends credential offer to Alice') + faberCredentialRecord = await faberAgent.credentials.acceptProposal(faberCredentialRecord.id) + + testLogger.test('Alice waits for credential from Faber') + aliceCredentialRecord = await waitForCredentialRecord(aliceAgent, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.CredentialReceived, + }) + + testLogger.test('Faber waits for credential ack from Alice') + faberCredentialRecord = await waitForCredentialRecord(faberAgent, { + 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), + schemaId, + credentialDefinitionId: credDefId, + }, + credentialId: expect.any(String), + state: CredentialState.Done, + }) + + expect(faberCredentialRecord).toMatchObject({ + type: CredentialRecord.name, + id: expect.any(String), + createdAt: expect.any(Date), + metadata: { + schemaId, + credentialDefinitionId: credDefId, + }, + offerMessage: expect.any(Object), + requestMessage: expect.any(Object), + state: CredentialState.Done, + }) + }) + + test('Faber starts with credential offer to Alice, both with autoAcceptCredential on `contentApproved`', async () => { + testLogger.test('Faber sends credential offer to Alice') + faberCredentialRecord = await faberAgent.credentials.offerCredential(faberConnection.id, { + preview: credentialPreview, + credentialDefinitionId: credDefId, + }) + + testLogger.test('Alice waits for credential offer from Faber') + aliceCredentialRecord = await waitForCredentialRecord(aliceAgent, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.OfferReceived, + }) + + expect(JsonTransformer.toJSON(aliceCredentialRecord)).toMatchObject({ + createdAt: expect.any(Date), + offerMessage: { + '@id': expect.any(String), + '@type': 'https://didcomm.org/issue-credential/1.0/offer-credential', + credential_preview: { + '@type': 'https://didcomm.org/issue-credential/1.0/credential-preview', + attributes: [ + { + name: 'name', + 'mime-type': 'text/plain', + value: 'John', + }, + { + name: 'age', + 'mime-type': 'text/plain', + value: '99', + }, + ], + }, + 'offers~attach': expect.any(Array), + }, + state: CredentialState.OfferReceived, + }) + + // below values are not in json object + expect(aliceCredentialRecord.id).not.toBeNull() + expect(aliceCredentialRecord.getTags()).toEqual({ + threadId: aliceCredentialRecord.threadId, + state: aliceCredentialRecord.state, + connectionId: aliceConnection.id, + }) + expect(aliceCredentialRecord.type).toBe(CredentialRecord.name) + + testLogger.test('alice sends credential request to faber') + aliceCredentialRecord = await aliceAgent.credentials.acceptOffer(aliceCredentialRecord.id) + + testLogger.test('Alice waits for credential from Faber') + aliceCredentialRecord = await waitForCredentialRecord(aliceAgent, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.CredentialReceived, + }) + + testLogger.test('Faber waits for credential ack from Alice') + faberCredentialRecord = await waitForCredentialRecord(faberAgent, { + 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, + }) + + 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, + }) + }) + + test('Alice starts with credential proposal to Faber, both have autoAcceptCredential on `contentApproved` and attributes did change', async () => { + testLogger.test('Alice sends credential proposal to Faber') + let aliceCredentialRecord = await aliceAgent.credentials.proposeCredential(aliceConnection.id, { + credentialProposal: credentialPreview, + credentialDefinitionId: credDefId, + }) + + testLogger.test('Faber waits for credential proposal from Alice') + let faberCredentialRecord = await waitForCredentialRecord(faberAgent, { + threadId: aliceCredentialRecord.threadId, + state: CredentialState.ProposalReceived, + }) + + faberCredentialRecord = await faberAgent.credentials.negotiateProposal( + faberCredentialRecord.id, + newCredentialPreview + ) + + testLogger.test('Alice waits for credential offer from Faber') + + aliceCredentialRecord = await waitForCredentialRecord(aliceAgent, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.OfferReceived, + }) + + expect(JsonTransformer.toJSON(aliceCredentialRecord)).toMatchObject({ + createdAt: expect.any(Date), + offerMessage: { + '@id': expect.any(String), + '@type': 'https://didcomm.org/issue-credential/1.0/offer-credential', + credential_preview: { + '@type': 'https://didcomm.org/issue-credential/1.0/credential-preview', + 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', + }, + ], + }, + 'offers~attach': expect.any(Array), + }, + state: CredentialState.OfferReceived, + }) + + // below values are not in json object + expect(aliceCredentialRecord.id).not.toBeNull() + expect(aliceCredentialRecord.getTags()).toEqual({ + threadId: aliceCredentialRecord.threadId, + state: aliceCredentialRecord.state, + connectionId: aliceConnection.id, + }) + expect(aliceCredentialRecord.type).toBe(CredentialRecord.name) + + // Wait for ten seconds + await sleep(5000) + + // Check if the state of the credential records did not change + faberCredentialRecord = await faberAgent.credentials.getById(faberCredentialRecord.id) + faberCredentialRecord.assertState(CredentialState.OfferSent) + + aliceCredentialRecord = await aliceAgent.credentials.getById(aliceCredentialRecord.id) + aliceCredentialRecord.assertState(CredentialState.OfferReceived) + }) + + test('Faber starts with credential offer to Alice, both have autoAcceptCredential on `contentApproved` and attributes did change', async () => { + testLogger.test('Faber sends credential offer to Alice') + faberCredentialRecord = await faberAgent.credentials.offerCredential(faberConnection.id, { + preview: credentialPreview, + credentialDefinitionId: credDefId, + }) + + testLogger.test('Alice waits for credential offer from Faber') + aliceCredentialRecord = await waitForCredentialRecord(aliceAgent, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.OfferReceived, + }) + + expect(JsonTransformer.toJSON(aliceCredentialRecord)).toMatchObject({ + createdAt: expect.any(Date), + offerMessage: { + '@id': expect.any(String), + '@type': 'https://didcomm.org/issue-credential/1.0/offer-credential', + credential_preview: { + '@type': 'https://didcomm.org/issue-credential/1.0/credential-preview', + attributes: [ + { + name: 'name', + 'mime-type': 'text/plain', + value: 'John', + }, + { + name: 'age', + 'mime-type': 'text/plain', + value: '99', + }, + ], + }, + 'offers~attach': expect.any(Array), + }, + state: CredentialState.OfferReceived, + }) + + // below values are not in json object + expect(aliceCredentialRecord.id).not.toBeNull() + expect(aliceCredentialRecord.getTags()).toEqual({ + threadId: aliceCredentialRecord.threadId, + state: aliceCredentialRecord.state, + connectionId: aliceConnection.id, + }) + expect(aliceCredentialRecord.type).toBe(CredentialRecord.name) + + testLogger.test('Alice sends credential request to Faber') + aliceCredentialRecord = await aliceAgent.credentials.negotiateOffer( + aliceCredentialRecord.id, + newCredentialPreview + ) + + expect(JsonTransformer.toJSON(aliceCredentialRecord)).toMatchObject({ + createdAt: expect.any(Date), + proposalMessage: { + '@type': 'https://didcomm.org/issue-credential/1.0/propose-credential', + '@id': expect.any(String), + credential_proposal: { + '@type': 'https://didcomm.org/issue-credential/1.0/credential-preview', + 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', + }, + ], + }, + '~thread': { thid: expect.any(String) }, + }, + state: CredentialState.ProposalSent, + }) + + // Wait for ten seconds + await sleep(5000) + + // Check if the state of fabers credential record did not change + faberCredentialRecord = await faberAgent.credentials.getById(faberCredentialRecord.id) + faberCredentialRecord.assertState(CredentialState.ProposalReceived) + + aliceCredentialRecord = await aliceAgent.credentials.getById(aliceCredentialRecord.id) + aliceCredentialRecord.assertState(CredentialState.ProposalSent) + }) + }) +}) diff --git a/src/__tests__/credentials.test.ts b/src/__tests__/credentials.test.ts index 4195b8bfba..d3de85c101 100644 --- a/src/__tests__/credentials.test.ts +++ b/src/__tests__/credentials.test.ts @@ -1,39 +1,19 @@ +import type { Agent } from '../agent/Agent' import type { ConnectionRecord } from '../modules/connections' -import type { WireMessage } from '../types' -import { Subject } from 'rxjs' - -import { SubjectInboundTransporter } from '../../tests/transport/SubjectInboundTransport' -import { SubjectOutboundTransporter } from '../../tests/transport/SubjectOutboundTransport' -import { Agent } from '../agent/Agent' import { Attachment, AttachmentData } from '../decorators/attachment/Attachment' import { - CredentialRecord, - CredentialState, CredentialPreview, CredentialPreviewAttribute, + CredentialRecord, + CredentialState, } from '../modules/credentials' import { JsonTransformer } from '../utils/JsonTransformer' import { LinkedAttachment } from '../utils/LinkedAttachment' -import { - ensurePublicDidIsOnLedger, - getBaseConfig, - makeConnection, - registerDefinition, - registerSchema, - waitForCredentialRecord, -} from './helpers' +import { setupCredentialTests, waitForCredentialRecord } from './helpers' import testLogger from './logger' -const faberConfig = getBaseConfig('Faber Credentials', { - endpoint: 'rxjs:faber', -}) - -const aliceConfig = getBaseConfig('Alice Credentials', { - endpoint: 'rxjs:alice', -}) - const credentialPreview = new CredentialPreview({ attributes: [ new CredentialPreviewAttribute({ @@ -60,45 +40,10 @@ describe('credentials', () => { let aliceCredentialRecord: CredentialRecord beforeAll(async () => { - const faberMessages = new Subject() - const aliceMessages = new Subject() - const subjectMap = { - 'rxjs:faber': faberMessages, - 'rxjs:alice': aliceMessages, - } - faberAgent = new Agent(faberConfig) - faberAgent.setInboundTransporter(new SubjectInboundTransporter(faberMessages)) - faberAgent.setOutboundTransporter(new SubjectOutboundTransporter(aliceMessages, subjectMap)) - await faberAgent.initialize() - - aliceAgent = new Agent(aliceConfig) - aliceAgent.setInboundTransporter(new SubjectInboundTransporter(aliceMessages)) - aliceAgent.setOutboundTransporter(new SubjectOutboundTransporter(faberMessages, subjectMap)) - await aliceAgent.initialize() - - const schemaTemplate = { - name: `test-schema-${Date.now()}`, - attributes: ['name', 'age', 'profile_picture', 'x-ray'], - version: '1.0', - } - const schema = await registerSchema(faberAgent, schemaTemplate) - schemaId = schema.id - - const definitionTemplate = { - schema, - tag: 'TAG', - signatureType: 'CL' as const, - supportRevocation: false, - } - const credentialDefinition = await registerDefinition(faberAgent, definitionTemplate) - credDefId = credentialDefinition.id - - const publicDid = faberAgent.publicDid?.did - - await ensurePublicDidIsOnLedger(faberAgent, publicDid!) - const [agentAConnection, agentBConnection] = await makeConnection(faberAgent, aliceAgent) - faberConnection = agentAConnection - aliceConnection = agentBConnection + ;({ faberAgent, aliceAgent, credDefId, schemaId, faberConnection, aliceConnection } = await setupCredentialTests( + 'faber agent alwaysx', + 'alice agent alwaysx' + )) }) afterAll(async () => { diff --git a/src/__tests__/helpers.ts b/src/__tests__/helpers.ts index 86a810636f..a33a710e50 100644 --- a/src/__tests__/helpers.ts +++ b/src/__tests__/helpers.ts @@ -1,15 +1,23 @@ -import type { Agent } from '../agent/Agent' import type { BasicMessage, BasicMessageReceivedEvent } from '../modules/basic-messages' import type { ConnectionRecordProps } from '../modules/connections' -import type { CredentialRecord, CredentialOfferTemplate, CredentialStateChangedEvent } from '../modules/credentials' -import type { SchemaTemplate, CredentialDefinitionTemplate } from '../modules/ledger' +import type { + AutoAcceptCredential, + CredentialOfferTemplate, + CredentialRecord, + CredentialStateChangedEvent, +} from '../modules/credentials' +import type { CredentialDefinitionTemplate, SchemaTemplate } from '../modules/ledger' import type { ProofAttributeInfo, ProofPredicateInfo, ProofRecord, ProofStateChangedEvent } from '../modules/proofs' -import type { InitConfig } from '../types' +import type { InitConfig, WireMessage } from '../types' import type { CredDef, Did, Schema } from 'indy-sdk' import indy from 'indy-sdk' import path from 'path' +import { Subject } from 'rxjs' +import { SubjectInboundTransporter } from '../../tests/transport/SubjectInboundTransport' +import { SubjectOutboundTransporter } from '../../tests/transport/SubjectOutboundTransport' +import { Agent } from '../agent/Agent' import { AriesFrameworkError } from '../error' import { LogLevel } from '../logger/Logger' import { BasicMessageEventTypes } from '../modules/basic-messages' @@ -22,9 +30,9 @@ import { DidDoc, } from '../modules/connections' import { + CredentialEventTypes, CredentialPreview, CredentialPreviewAttribute, - CredentialEventTypes, CredentialState, } from '../modules/credentials' import { ProofEventTypes, ProofState } from '../modules/proofs' @@ -271,6 +279,9 @@ export async function ensurePublicDidIsOnLedger(agent: Agent, publicDid: Did) { } } +/** + * Assumes that the autoAcceptCredential is set to {@link AutoAcceptCredential.ContentApproved} + */ export async function issueCredential({ issuerAgent, issuerConnectionId, @@ -289,27 +300,17 @@ export async function issueCredential({ state: CredentialState.OfferReceived, }) - let issuerCredentialRecordPromise = waitForCredentialRecord(issuerAgent, { - threadId: holderCredentialRecord.threadId, - state: CredentialState.RequestReceived, - }) - holderCredentialRecord = await holderAgent.credentials.acceptOffer(holderCredentialRecord.id) - issuerCredentialRecord = await issuerCredentialRecordPromise + await holderAgent.credentials.acceptOffer(holderCredentialRecord.id) - const holderCredentialRecordPromise = waitForCredentialRecord(holderAgent, { + holderCredentialRecord = await waitForCredentialRecord(holderAgent, { threadId: issuerCredentialRecord.threadId, - state: CredentialState.CredentialReceived, + state: CredentialState.Done, }) - issuerCredentialRecord = await issuerAgent.credentials.acceptRequest(issuerCredentialRecord.id) - await holderCredentialRecordPromise - issuerCredentialRecordPromise = waitForCredentialRecord(issuerAgent, { + issuerCredentialRecord = await waitForCredentialRecord(issuerAgent, { threadId: issuerCredentialRecord.threadId, state: CredentialState.Done, }) - holderCredentialRecord = await holderAgent.credentials.acceptCredential(holderCredentialRecord.id) - - issuerCredentialRecord = await issuerCredentialRecordPromise return { issuerCredential: issuerCredentialRecord, @@ -385,3 +386,63 @@ export async function presentProof({ export function mockFunction any>(fn: T): jest.MockedFunction { return fn as jest.MockedFunction } + +export async function setupCredentialTests( + faberName: string, + aliceName: string, + autoAcceptCredentials?: AutoAcceptCredential +) { + const faberMessages = new Subject() + const aliceMessages = new Subject() + const subjectMap = { + 'rxjs:faber': faberMessages, + 'rxjs:alice': aliceMessages, + } + const faberConfig = getBaseConfig(faberName, { + genesisPath, + endpoint: 'rxjs:faber', + autoAcceptCredentials, + }) + + const aliceConfig = getBaseConfig(aliceName, { + genesisPath, + endpoint: 'rxjs:alice', + autoAcceptCredentials, + }) + const faberAgent = new Agent(faberConfig) + faberAgent.setInboundTransporter(new SubjectInboundTransporter(faberMessages)) + faberAgent.setOutboundTransporter(new SubjectOutboundTransporter(aliceMessages, subjectMap)) + await faberAgent.initialize() + + const aliceAgent = new Agent(aliceConfig) + aliceAgent.setInboundTransporter(new SubjectInboundTransporter(aliceMessages)) + aliceAgent.setOutboundTransporter(new SubjectOutboundTransporter(faberMessages, subjectMap)) + await aliceAgent.initialize() + + const schemaTemplate = { + name: `test-schema-${Date.now()}`, + attributes: ['name', 'age', 'profile_picture', 'x-ray'], + version: '1.0', + } + const schema = await registerSchema(faberAgent, schemaTemplate) + const schemaId = schema.id + + 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) + const faberConnection = agentAConnection + const aliceConnection = agentBConnection + + return { faberAgent, aliceAgent, credDefId, schemaId, faberConnection, aliceConnection } +} diff --git a/src/__tests__/proofs.test.ts b/src/__tests__/proofs.test.ts index 7cfd594f83..c24c83c5ca 100644 --- a/src/__tests__/proofs.test.ts +++ b/src/__tests__/proofs.test.ts @@ -8,7 +8,7 @@ import { SubjectInboundTransporter } from '../../tests/transport/SubjectInboundT import { SubjectOutboundTransporter } from '../../tests/transport/SubjectOutboundTransport' import { Agent } from '../agent/Agent' import { Attachment, AttachmentData } from '../decorators/attachment/Attachment' -import { CredentialPreview, CredentialPreviewAttribute } from '../modules/credentials' +import { AutoAcceptCredential, CredentialPreview, CredentialPreviewAttribute } from '../modules/credentials' import { PredicateType, PresentationPreview, @@ -32,8 +32,14 @@ import { } from './helpers' import testLogger from './logger' -const faberConfig = getBaseConfig('Faber Proofs', { endpoint: 'rxjs:faber' }) -const aliceConfig = getBaseConfig('Alice Proofs', { endpoint: 'rxjs:alice' }) +const faberConfig = getBaseConfig('Faber Proofs', { + endpoint: 'rxjs:faber', + autoAcceptCredentials: AutoAcceptCredential.ContentApproved, +}) +const aliceConfig = getBaseConfig('Alice Proofs', { + endpoint: 'rxjs:alice', + autoAcceptCredentials: AutoAcceptCredential.ContentApproved, +}) const credentialPreview = new CredentialPreview({ attributes: [ diff --git a/src/agent/AgentConfig.ts b/src/agent/AgentConfig.ts index 3dbc14c858..a6ea711656 100644 --- a/src/agent/AgentConfig.ts +++ b/src/agent/AgentConfig.ts @@ -4,6 +4,7 @@ import type { InitConfig } from '../types' import { DID_COMM_TRANSPORT_QUEUE } from '../constants' import { AriesFrameworkError } from '../error' import { ConsoleLogger, LogLevel } from '../logger' +import { AutoAcceptCredential } from '../modules/credentials/CredentialAutoAcceptType' import { MediatorPickupStrategy } from '../modules/routing/MediatorPickupStrategy' import { DidCommMimeType } from '../types' @@ -65,6 +66,10 @@ export class AgentConfig { return this.initConfig.autoAcceptConnections ?? false } + public get autoAcceptCredentials() { + return this.initConfig.autoAcceptCredentials ?? AutoAcceptCredential.Never + } + public get didCommMimeType() { return this.initConfig.didCommMimeType ?? DidCommMimeType.V0 } diff --git a/src/modules/credentials/CredentialAutoAcceptType.ts b/src/modules/credentials/CredentialAutoAcceptType.ts new file mode 100644 index 0000000000..79d11568e7 --- /dev/null +++ b/src/modules/credentials/CredentialAutoAcceptType.ts @@ -0,0 +1,13 @@ +/** + * Typing of the state for auto acceptance + */ +export enum AutoAcceptCredential { + // Always auto accepts the credential no matter if it changed in subsequent steps + Always = 'always', + + // Needs one acceptation and the rest will be automated if nothing changes + ContentApproved = 'contentApproved', + + // Never auto accept a credential + Never = 'never', +} diff --git a/src/modules/credentials/CredentialResponseCoordinator.ts b/src/modules/credentials/CredentialResponseCoordinator.ts new file mode 100644 index 0000000000..d076596061 --- /dev/null +++ b/src/modules/credentials/CredentialResponseCoordinator.ts @@ -0,0 +1,168 @@ +import type { CredentialRecord } from './repository' + +import { scoped, Lifecycle } from 'tsyringe' + +import { AgentConfig } from '../../agent/AgentConfig' + +import { AutoAcceptCredential } from './CredentialAutoAcceptType' +import { CredentialUtils } from './CredentialUtils' + +/** + * This class handles all the automation with all the messages in the issue credential protocol + * Every function returns `true` if it should automate the flow and `false` if not + */ +@scoped(Lifecycle.ContainerScoped) +export class CredentialResponseCoordinator { + private agentConfig: AgentConfig + + public constructor(agentConfig: AgentConfig) { + this.agentConfig = agentConfig + } + + /** + * Returns the credential auto accept config based on priority: + * - The record config takes first priority + * - Otherwise the agent config + * - Otherwise {@link AutoAcceptCredential.Never} is returned + */ + private static composeAutoAccept( + recordConfig: AutoAcceptCredential | undefined, + agentConfig: AutoAcceptCredential | undefined + ) { + return recordConfig ?? agentConfig ?? AutoAcceptCredential.Never + } + + /** + * Checks whether it should automatically respond to a proposal + */ + public shouldAutoRespondToProposal(credentialRecord: CredentialRecord) { + const autoAccept = CredentialResponseCoordinator.composeAutoAccept( + credentialRecord.autoAcceptCredential, + this.agentConfig.autoAcceptCredentials + ) + + if (autoAccept === AutoAcceptCredential.Always) { + return true + } else if (autoAccept === AutoAcceptCredential.ContentApproved) { + return ( + this.areProposalValuesValid(credentialRecord) && this.areProposalAndOfferDefinitionIdEqual(credentialRecord) + ) + } + return false + } + + /** + * Checks whether it should automatically respond to an offer + */ + public shouldAutoRespondToOffer(credentialRecord: CredentialRecord) { + const autoAccept = CredentialResponseCoordinator.composeAutoAccept( + credentialRecord.autoAcceptCredential, + this.agentConfig.autoAcceptCredentials + ) + + if (autoAccept === AutoAcceptCredential.Always) { + return true + } else if (autoAccept === AutoAcceptCredential.ContentApproved) { + return this.areOfferValuesValid(credentialRecord) && this.areProposalAndOfferDefinitionIdEqual(credentialRecord) + } + return false + } + + /** + * Checks whether it should automatically respond to a request + */ + public shouldAutoRespondToRequest(credentialRecord: CredentialRecord) { + const autoAccept = CredentialResponseCoordinator.composeAutoAccept( + credentialRecord.autoAcceptCredential, + this.agentConfig.autoAcceptCredentials + ) + + if (autoAccept === AutoAcceptCredential.Always) { + return true + } else if (autoAccept === AutoAcceptCredential.ContentApproved) { + return this.isRequestDefinitionIdValid(credentialRecord) + } + return false + } + + /** + * Checks whether it should automatically respond to the issuance of a credential + */ + public shouldAutoRespondToIssue(credentialRecord: CredentialRecord) { + const autoAccept = CredentialResponseCoordinator.composeAutoAccept( + credentialRecord.autoAcceptCredential, + this.agentConfig.autoAcceptCredentials + ) + + if (autoAccept === AutoAcceptCredential.Always) { + return true + } else if (autoAccept === AutoAcceptCredential.ContentApproved) { + return this.areCredentialValuesValid(credentialRecord) + } + return false + } + + private areProposalValuesValid(credentialRecord: CredentialRecord) { + const { proposalMessage, credentialAttributes } = credentialRecord + + if (proposalMessage && proposalMessage.credentialProposal && credentialAttributes) { + const proposalValues = CredentialUtils.convertAttributesToValues(proposalMessage.credentialProposal.attributes) + const defaultValues = CredentialUtils.convertAttributesToValues(credentialAttributes) + if (CredentialUtils.checkValuesMatch(proposalValues, defaultValues)) { + return true + } + } + return false + } + + private areOfferValuesValid(credentialRecord: CredentialRecord) { + const { offerMessage, credentialAttributes } = credentialRecord + + if (offerMessage && credentialAttributes) { + const offerValues = CredentialUtils.convertAttributesToValues(offerMessage.credentialPreview.attributes) + const defaultValues = CredentialUtils.convertAttributesToValues(credentialAttributes) + if (CredentialUtils.checkValuesMatch(offerValues, defaultValues)) { + return true + } + } + return false + } + + private areCredentialValuesValid(credentialRecord: CredentialRecord) { + if (credentialRecord.credentialAttributes && credentialRecord.credentialMessage) { + const indyCredential = credentialRecord.credentialMessage.indyCredential + + if (!indyCredential) { + this.agentConfig.logger.error(`Missing required base64 encoded attachment data for credential`) + return false + } + + const credentialMessageValues = indyCredential.values + const defaultValues = CredentialUtils.convertAttributesToValues(credentialRecord.credentialAttributes) + + if (CredentialUtils.checkValuesMatch(credentialMessageValues, defaultValues)) { + return true + } + } + return false + } + + private areProposalAndOfferDefinitionIdEqual(credentialRecord: CredentialRecord) { + const proposalCredentialDefinitionId = credentialRecord.proposalMessage?.credentialDefinitionId + const offerCredentialDefinitionId = credentialRecord.offerMessage?.indyCredentialOffer?.cred_def_id + return proposalCredentialDefinitionId === offerCredentialDefinitionId + } + + private isRequestDefinitionIdValid(credentialRecord: CredentialRecord) { + if (credentialRecord.proposalMessage || credentialRecord.offerMessage) { + const previousCredentialDefinitionId = + credentialRecord.offerMessage?.indyCredentialOffer?.cred_def_id ?? + credentialRecord.proposalMessage?.credentialDefinitionId + + if (previousCredentialDefinitionId === credentialRecord.requestMessage?.indyCredentialRequest?.cred_def_id) { + return true + } + } + return false + } +} diff --git a/src/modules/credentials/CredentialUtils.ts b/src/modules/credentials/CredentialUtils.ts index f46046a501..6ca15deaa1 100644 --- a/src/modules/credentials/CredentialUtils.ts +++ b/src/modules/credentials/CredentialUtils.ts @@ -61,6 +61,21 @@ export class CredentialUtils { }, {}) } + /** + * Check whether the values of two credentials match (using {@link assertValuesMatch}) + * + * @returns a boolean whether the values are equal + * + */ + public static checkValuesMatch(firstValues: CredValues, secondValues: CredValues): boolean { + try { + this.assertValuesMatch(firstValues, secondValues) + return true + } catch { + return false + } + } + /** * Assert two credential values objects match. * diff --git a/src/modules/credentials/CredentialsModule.ts b/src/modules/credentials/CredentialsModule.ts index ed105f7402..d7fb069ac5 100644 --- a/src/modules/credentials/CredentialsModule.ts +++ b/src/modules/credentials/CredentialsModule.ts @@ -1,8 +1,11 @@ +import type { AutoAcceptCredential } from './CredentialAutoAcceptType' +import type { CredentialPreview } from './messages' import type { CredentialRecord } from './repository/CredentialRecord' import type { CredentialOfferTemplate, CredentialProposeOptions } from './services' import { inject, Lifecycle, scoped } from 'tsyringe' +import { AgentConfig } from '../../agent/AgentConfig' import { Dispatcher } from '../../agent/Dispatcher' import { MessageSender } from '../../agent/MessageSender' import { createOutboundMessage } from '../../agent/helpers' @@ -12,6 +15,7 @@ import { Logger } from '../../logger' import { isLinkedAttachment } from '../../utils/attachment' import { ConnectionService } from '../connections/services/ConnectionService' +import { CredentialResponseCoordinator } from './CredentialResponseCoordinator' import { CredentialAckHandler, IssueCredentialHandler, @@ -26,6 +30,8 @@ export class CredentialsModule { private connectionService: ConnectionService private credentialService: CredentialService private messageSender: MessageSender + private agentConfig: AgentConfig + private credentialResponseCoordinator: CredentialResponseCoordinator private logger: Logger public constructor( @@ -33,11 +39,15 @@ export class CredentialsModule { connectionService: ConnectionService, credentialService: CredentialService, messageSender: MessageSender, + agentConfig: AgentConfig, + credentialResponseCoordinator: CredentialResponseCoordinator, @inject(InjectionSymbols.Logger) logger: Logger ) { this.connectionService = connectionService this.credentialService = credentialService this.messageSender = messageSender + this.agentConfig = agentConfig + this.credentialResponseCoordinator = credentialResponseCoordinator this.logger = logger this.registerHandlers(dispatcher) } @@ -75,6 +85,7 @@ export class CredentialsModule { config?: { comment?: string credentialDefinitionId?: string + autoAcceptCredential?: AutoAcceptCredential } ) { const credentialRecord = await this.credentialService.getById(credentialRecordId) @@ -105,6 +116,59 @@ export class CredentialsModule { preview: credentialProposalMessage.credentialProposal, credentialDefinitionId, comment: config?.comment, + autoAcceptCredential: config?.autoAcceptCredential, + attachments: credentialRecord.linkedAttachments, + }) + + const outboundMessage = createOutboundMessage(connection, message) + await this.messageSender.sendMessage(outboundMessage) + + return credentialRecord + } + + /** + * Negotiate a credential proposal as issuer (by sending a credential offer message) to the connection + * associated with the credential record. + * + * @param credentialRecordId The id of the credential record for which to accept the proposal + * @param preview The new preview for negotiation + * @param config Additional configuration to use for the offer + * @returns Credential record associated with the credential offer + * + */ + public async negotiateProposal( + credentialRecordId: string, + preview: CredentialPreview, + config?: { + comment?: string + credentialDefinitionId?: string + autoAcceptCredential?: AutoAcceptCredential + } + ) { + const credentialRecord = await this.credentialService.getById(credentialRecordId) + 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` + ) + } + + const credentialDefinitionId = config?.credentialDefinitionId ?? credentialProposalMessage.credentialDefinitionId + + if (!credentialDefinitionId) { + throw new AriesFrameworkError( + 'Missing required credential definition id. If credential proposal message contains no credential definition id it must be passed to config.' + ) + } + + const { message } = await this.credentialService.createOfferAsResponse(credentialRecord, { + preview, + credentialDefinitionId, + comment: config?.comment, + autoAcceptCredential: config?.autoAcceptCredential, attachments: credentialRecord.linkedAttachments, }) @@ -145,7 +209,10 @@ export class CredentialsModule { * @returns Credential record associated with the sent credential request message * */ - public async acceptOffer(credentialRecordId: string, config?: { comment?: string }) { + 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) @@ -157,6 +224,35 @@ export class CredentialsModule { return credentialRecord } + /** + * Negotiate a credential offer as holder (by sending a credential proposal message) to the connection + * associated with the credential record. + * + * @param credentialRecordId The id of the credential record for which to accept the offer + * @param preview The new preview for negotiation + * @param config Additional configuration to use for the request + * @returns Credential record associated with the sent credential request message + * + */ + public async negotiateOffer( + credentialRecordId: string, + preview: CredentialPreview, + config?: { comment?: string; autoAcceptCredential?: AutoAcceptCredential } + ) { + const credentialRecord = await this.credentialService.getById(credentialRecordId) + const connection = await this.connectionService.getById(credentialRecord.connectionId) + + const { message } = await this.credentialService.createProposalAsResponse(credentialRecord, { + ...config, + credentialProposal: preview, + }) + + const outboundMessage = createOutboundMessage(connection, message) + await this.messageSender.sendMessage(outboundMessage) + + return credentialRecord + } + /** * Accept a credential request as issuer (by sending a credential message) to the connection * associated with the credential record. @@ -166,7 +262,10 @@ export class CredentialsModule { * @returns Credential record associated with the sent presentation message * */ - public async acceptRequest(credentialRecordId: string, config?: { comment?: string }) { + public async acceptRequest( + credentialRecordId: string, + config?: { comment?: string; autoAcceptCredential?: AutoAcceptCredential } + ) { const credentialRecord = await this.credentialService.getById(credentialRecordId) const connection = await this.connectionService.getById(credentialRecord.connectionId) @@ -243,10 +342,18 @@ export class CredentialsModule { } private registerHandlers(dispatcher: Dispatcher) { - dispatcher.registerHandler(new ProposeCredentialHandler(this.credentialService)) - dispatcher.registerHandler(new OfferCredentialHandler(this.credentialService)) - dispatcher.registerHandler(new RequestCredentialHandler(this.credentialService)) - dispatcher.registerHandler(new IssueCredentialHandler(this.credentialService)) + dispatcher.registerHandler( + new ProposeCredentialHandler(this.credentialService, this.agentConfig, this.credentialResponseCoordinator) + ) + dispatcher.registerHandler( + new OfferCredentialHandler(this.credentialService, this.agentConfig, this.credentialResponseCoordinator) + ) + dispatcher.registerHandler( + new RequestCredentialHandler(this.credentialService, this.agentConfig, this.credentialResponseCoordinator) + ) + dispatcher.registerHandler( + new IssueCredentialHandler(this.credentialService, this.agentConfig, this.credentialResponseCoordinator) + ) dispatcher.registerHandler(new CredentialAckHandler(this.credentialService)) } } diff --git a/src/modules/credentials/handlers/IssueCredentialHandler.ts b/src/modules/credentials/handlers/IssueCredentialHandler.ts index 0e85540756..972e5ec337 100644 --- a/src/modules/credentials/handlers/IssueCredentialHandler.ts +++ b/src/modules/credentials/handlers/IssueCredentialHandler.ts @@ -1,17 +1,49 @@ +import type { AgentConfig } from '../../../agent/AgentConfig' import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' +import type { CredentialResponseCoordinator } from '../CredentialResponseCoordinator' +import type { CredentialRecord } from '../repository/CredentialRecord' import type { CredentialService } from '../services' +import { createOutboundMessage } from '../../../agent/helpers' import { IssueCredentialMessage } from '../messages' export class IssueCredentialHandler implements Handler { private credentialService: CredentialService + private agentConfig: AgentConfig + private credentialResponseCoordinator: CredentialResponseCoordinator public supportedMessages = [IssueCredentialMessage] - public constructor(credentialService: CredentialService) { + public constructor( + credentialService: CredentialService, + agentConfig: AgentConfig, + credentialResponseCoordinator: CredentialResponseCoordinator + ) { this.credentialService = credentialService + this.agentConfig = agentConfig + this.credentialResponseCoordinator = credentialResponseCoordinator } public async handle(messageContext: HandlerInboundMessage) { - await this.credentialService.processCredential(messageContext) + const credentialRecord = await this.credentialService.processCredential(messageContext) + if (this.credentialResponseCoordinator.shouldAutoRespondToIssue(credentialRecord)) { + return await this.createAck(credentialRecord, messageContext) + } + } + + private async createAck( + credentialRecord: CredentialRecord, + messageContext: HandlerInboundMessage + ) { + this.agentConfig.logger.info( + `Automatically sending acknowledgement with autoAccept on ${this.agentConfig.autoAcceptCredentials}` + ) + + if (!messageContext.connection) { + this.agentConfig.logger.error(`No connection on the messageContext`) + return + } + const { message } = await this.credentialService.createAck(credentialRecord) + + return createOutboundMessage(messageContext.connection, message) } } diff --git a/src/modules/credentials/handlers/OfferCredentialHandler.ts b/src/modules/credentials/handlers/OfferCredentialHandler.ts index c21e350a37..6413a87f57 100644 --- a/src/modules/credentials/handlers/OfferCredentialHandler.ts +++ b/src/modules/credentials/handlers/OfferCredentialHandler.ts @@ -1,17 +1,51 @@ +import type { AgentConfig } from '../../../agent/AgentConfig' import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' +import type { CredentialResponseCoordinator } from '../CredentialResponseCoordinator' +import type { CredentialRecord } from '../repository/CredentialRecord' import type { CredentialService } from '../services' +import { createOutboundMessage } from '../../../agent/helpers' import { OfferCredentialMessage } from '../messages' export class OfferCredentialHandler implements Handler { private credentialService: CredentialService + private agentConfig: AgentConfig + private credentialReponseCoordinator: CredentialResponseCoordinator public supportedMessages = [OfferCredentialMessage] - public constructor(credentialService: CredentialService) { + public constructor( + credentialService: CredentialService, + agentConfig: AgentConfig, + credentialResponseCoordinator: CredentialResponseCoordinator + ) { this.credentialService = credentialService + this.agentConfig = agentConfig + this.credentialReponseCoordinator = credentialResponseCoordinator } public async handle(messageContext: HandlerInboundMessage) { - await this.credentialService.processOffer(messageContext) + const credentialRecord = await this.credentialService.processOffer(messageContext) + + if (this.credentialReponseCoordinator.shouldAutoRespondToOffer(credentialRecord)) { + return await this.createRequest(credentialRecord, messageContext) + } + } + + private async createRequest( + credentialRecord: 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 + } + + const { message } = await this.credentialService.createRequest(credentialRecord) + + return createOutboundMessage(messageContext.connection, message) } } diff --git a/src/modules/credentials/handlers/ProposeCredentialHandler.ts b/src/modules/credentials/handlers/ProposeCredentialHandler.ts index 2021601cab..48eb7dd11e 100644 --- a/src/modules/credentials/handlers/ProposeCredentialHandler.ts +++ b/src/modules/credentials/handlers/ProposeCredentialHandler.ts @@ -1,17 +1,65 @@ +import type { AgentConfig } from '../../../agent/AgentConfig' import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' +import type { CredentialResponseCoordinator } from '../CredentialResponseCoordinator' +import type { CredentialRecord } from '../repository/CredentialRecord' import type { CredentialService } from '../services' +import { createOutboundMessage } from '../../../agent/helpers' import { ProposeCredentialMessage } from '../messages' export class ProposeCredentialHandler implements Handler { private credentialService: CredentialService + private agentConfig: AgentConfig + private credentialAutoResponseCoordinator: CredentialResponseCoordinator public supportedMessages = [ProposeCredentialMessage] - public constructor(credentialService: CredentialService) { + public constructor( + credentialService: CredentialService, + agentConfig: AgentConfig, + responseCoordinator: CredentialResponseCoordinator + ) { + this.credentialAutoResponseCoordinator = responseCoordinator this.credentialService = credentialService + this.agentConfig = agentConfig } public async handle(messageContext: HandlerInboundMessage) { - await this.credentialService.processProposal(messageContext) + const credentialRecord = await this.credentialService.processProposal(messageContext) + if (this.credentialAutoResponseCoordinator.shouldAutoRespondToProposal(credentialRecord)) { + return await this.createOffer(credentialRecord, messageContext) + } + } + + private async createOffer( + credentialRecord: CredentialRecord, + messageContext: HandlerInboundMessage + ) { + this.agentConfig.logger.info( + `Automatically sending offer with autoAccept on ${this.agentConfig.autoAcceptCredentials}` + ) + + if (!messageContext.connection) { + this.agentConfig.logger.error('No connection on the messageContext, aborting auto accept') + return + } + + if (!credentialRecord.proposalMessage?.credentialProposal) { + this.agentConfig.logger.error( + `Credential record with id ${credentialRecord.id} is missing required credential proposal` + ) + return + } + + if (!credentialRecord.proposalMessage.credentialDefinitionId) { + this.agentConfig.logger.error('Missing required credential definition id') + return + } + + const { message } = await this.credentialService.createOfferAsResponse(credentialRecord, { + credentialDefinitionId: credentialRecord.proposalMessage.credentialDefinitionId, + preview: credentialRecord.proposalMessage.credentialProposal, + }) + + return createOutboundMessage(messageContext.connection, message) } } diff --git a/src/modules/credentials/handlers/RequestCredentialHandler.ts b/src/modules/credentials/handlers/RequestCredentialHandler.ts index 164d30e3f2..476ddac778 100644 --- a/src/modules/credentials/handlers/RequestCredentialHandler.ts +++ b/src/modules/credentials/handlers/RequestCredentialHandler.ts @@ -1,17 +1,50 @@ +import type { AgentConfig } from '../../../agent/AgentConfig' import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' +import type { CredentialResponseCoordinator } from '../CredentialResponseCoordinator' +import type { CredentialRecord } from '../repository/CredentialRecord' import type { CredentialService } from '../services' +import { createOutboundMessage } from '../../../agent/helpers' import { RequestCredentialMessage } from '../messages' export class RequestCredentialHandler implements Handler { + private agentConfig: AgentConfig private credentialService: CredentialService + private credentialResponseCoordinator: CredentialResponseCoordinator public supportedMessages = [RequestCredentialMessage] - public constructor(credentialService: CredentialService) { + public constructor( + credentialService: CredentialService, + agentConfig: AgentConfig, + credentialResponseCoordinator: CredentialResponseCoordinator + ) { this.credentialService = credentialService + this.agentConfig = agentConfig + this.credentialResponseCoordinator = credentialResponseCoordinator } public async handle(messageContext: HandlerInboundMessage) { - await this.credentialService.processRequest(messageContext) + const credentialRecord = await this.credentialService.processRequest(messageContext) + if (this.credentialResponseCoordinator.shouldAutoRespondToRequest(credentialRecord)) { + return await this.createCredential(credentialRecord, messageContext) + } + } + + private async createCredential( + credentialRecord: 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 } = await this.credentialService.createCredential(credentialRecord) + + return createOutboundMessage(messageContext.connection, message) } } diff --git a/src/modules/credentials/index.ts b/src/modules/credentials/index.ts index fb50307687..890024a880 100644 --- a/src/modules/credentials/index.ts +++ b/src/modules/credentials/index.ts @@ -6,3 +6,4 @@ export * from './repository' export * from './CredentialState' export * from './CredentialEvents' export * from './CredentialsModule' +export * from './CredentialAutoAcceptType' diff --git a/src/modules/credentials/repository/CredentialRecord.ts b/src/modules/credentials/repository/CredentialRecord.ts index e8cd1fda64..b3e7b0ff67 100644 --- a/src/modules/credentials/repository/CredentialRecord.ts +++ b/src/modules/credentials/repository/CredentialRecord.ts @@ -1,4 +1,5 @@ import type { TagsBase } from '../../../storage/BaseRecord' +import type { AutoAcceptCredential } from '../CredentialAutoAcceptType' import type { CredentialState } from '../CredentialState' import { Type } from 'class-transformer' @@ -37,6 +38,7 @@ export interface CredentialRecordProps { requestMessage?: RequestCredentialMessage credentialMessage?: IssueCredentialMessage credentialAttributes?: CredentialPreviewAttribute[] + autoAcceptCredential?: AutoAcceptCredential linkedAttachments?: Attachment[] } @@ -54,6 +56,7 @@ export class CredentialRecord extends BaseRecord ProposeCredentialMessage) @@ -92,6 +95,7 @@ export class CredentialRecord extends BaseRecord linkedAttachment.attachment), credentialAttributes: proposalMessage.credentialProposal?.attributes, + autoAcceptCredential: config?.autoAcceptCredential, }) await this.credentialRepository.save(credentialRecord) this.eventEmitter.emit({ @@ -127,7 +129,7 @@ export class CredentialService { */ public async createProposalAsResponse( credentialRecord: CredentialRecord, - config?: Omit + config?: CredentialProposeOptions ): Promise> { // Assert credentialRecord.assertState(CredentialState.OfferReceived) @@ -177,7 +179,6 @@ export class CredentialService { // Update record credentialRecord.proposalMessage = proposalMessage - credentialRecord.credentialAttributes = proposalMessage.credentialProposal?.attributes await this.updateState(credentialRecord, CredentialState.ProposalReceived) } catch { // No credential record exists with thread id @@ -246,6 +247,9 @@ export class CredentialService { credentialRecord.metadata.credentialDefinitionId = credOffer.cred_def_id credentialRecord.metadata.schemaId = credOffer.schema_id credentialRecord.linkedAttachments = attachments?.filter((attachment) => isLinkedAttachment(attachment)) + credentialRecord.autoAcceptCredential = + credentialTemplate.autoAcceptCredential ?? credentialRecord.autoAcceptCredential + await this.updateState(credentialRecord, CredentialState.OfferSent) return { message: credentialOfferMessage, credentialRecord } @@ -303,6 +307,7 @@ export class CredentialService { schemaId: credOffer.schema_id, }, state: CredentialState.OfferSent, + autoAcceptCredential: credentialTemplate.autoAcceptCredential, }) await this.credentialRepository.save(credentialRecord) @@ -355,7 +360,6 @@ export class CredentialService { credentialRecord.assertState(CredentialState.ProposalSent) credentialRecord.offerMessage = credentialOfferMessage - credentialRecord.credentialAttributes = credentialOfferMessage.credentialPreview.attributes credentialRecord.linkedAttachments = credentialOfferMessage.attachments?.filter((attachment) => isLinkedAttachment(attachment) ) @@ -400,7 +404,7 @@ export class CredentialService { */ public async createRequest( credentialRecord: CredentialRecord, - options: CredentialRequestOptions = {} + options?: CredentialRequestOptions ): Promise> { // Assert credential credentialRecord.assertState(CredentialState.OfferReceived) @@ -432,9 +436,8 @@ export class CredentialService { }), }) - const { comment } = options const credentialRequest = new RequestCredentialMessage({ - comment, + comment: options?.comment, requestAttachments: [requestAttachment], attachments: credentialRecord.offerMessage?.attachments?.filter((attachment) => isLinkedAttachment(attachment)), }) @@ -442,6 +445,8 @@ export class CredentialService { credentialRecord.metadata.requestMetadata = credReqMetadata credentialRecord.requestMessage = credentialRequest + credentialRecord.autoAcceptCredential = options?.autoAcceptCredential ?? credentialRecord.autoAcceptCredential + credentialRecord.linkedAttachments = credentialRecord.offerMessage?.attachments?.filter((attachment) => isLinkedAttachment(attachment) ) @@ -502,7 +507,7 @@ export class CredentialService { */ public async createCredential( credentialRecord: CredentialRecord, - options: CredentialResponseOptions = {} + options?: CredentialResponseOptions ): Promise> { // Assert credentialRecord.assertState(CredentialState.RequestReceived) @@ -555,10 +560,8 @@ export class CredentialService { }), }) - const { comment } = options - const issueCredentialMessage = new IssueCredentialMessage({ - comment, + comment: options?.comment, credentialAttachments: [credentialAttachment], attachments: offerMessage?.attachments?.filter((attachment) => isLinkedAttachment(attachment)) || @@ -570,6 +573,7 @@ export class CredentialService { issueCredentialMessage.setPleaseAck() credentialRecord.credentialMessage = issueCredentialMessage + credentialRecord.autoAcceptCredential = options?.autoAcceptCredential ?? credentialRecord.autoAcceptCredential await this.updateState(credentialRecord, CredentialState.CredentialIssued) @@ -615,18 +619,6 @@ export class CredentialService { ) } - // Assert the values in the received credential match the values - // that were negotiated in the credential exchange - // TODO: Ideally we don't throw here, but instead store that it's not equal. - // the credential may still have value, and we could just respond with an ack - // status of fail - if (credentialRecord.credentialAttributes) { - CredentialUtils.assertValuesMatch( - indyCredential.values, - CredentialUtils.convertAttributesToValues(credentialRecord.credentialAttributes) - ) - } - const credentialDefinition = await this.ledgerService.getCredentialDefinition(indyCredential.cred_def_id) const credentialId = await this.indyHolderService.storeCredential({ @@ -772,18 +764,22 @@ export interface CredentialOfferTemplate { credentialDefinitionId: CredDefId comment?: string preview: CredentialPreview + autoAcceptCredential?: AutoAcceptCredential attachments?: Attachment[] linkedAttachments?: LinkedAttachment[] } export interface CredentialRequestOptions { comment?: string + autoAcceptCredential?: AutoAcceptCredential } export interface CredentialResponseOptions { comment?: string + autoAcceptCredential?: AutoAcceptCredential } export type CredentialProposeOptions = Omit & { linkedAttachments?: LinkedAttachment[] + autoAcceptCredential?: AutoAcceptCredential } diff --git a/src/modules/proofs/services/ProofService.ts b/src/modules/proofs/services/ProofService.ts index e64d454dda..a4297ea30e 100644 --- a/src/modules/proofs/services/ProofService.ts +++ b/src/modules/proofs/services/ProofService.ts @@ -655,6 +655,7 @@ export class ProofService { // Only continues if there is an attribute value that contains a hashlink for (const credentialId of credentialIds) { // Get the credentialRecord that matches the ID + const credentialRecord = await this.credentialRepository.getSingleByQuery({ credentialId }) if (credentialRecord.linkedAttachments) { diff --git a/src/types.ts b/src/types.ts index 4a6c1aaf8f..d3a315b8ab 100644 --- a/src/types.ts +++ b/src/types.ts @@ -2,9 +2,10 @@ 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 { AutoAcceptCredential } from './modules/credentials/CredentialAutoAcceptType' import type { MediatorPickupStrategy } from './modules/routing' import type { FileSystem } from './storage/fs/FileSystem' -import type { default as Indy, WalletConfig, WalletCredentials, Verkey } from 'indy-sdk' +import type { default as Indy, Verkey, WalletConfig, WalletCredentials } from 'indy-sdk' // eslint-disable-next-line @typescript-eslint/no-explicit-any type $FixMe = any @@ -26,6 +27,7 @@ export interface InitConfig { walletConfig?: WalletConfig walletCredentials?: WalletCredentials autoAcceptConnections?: boolean + autoAcceptCredentials?: AutoAcceptCredential poolName?: string logger?: Logger indy: typeof Indy diff --git a/tests/e2e.test.ts b/tests/e2e.test.ts index f1133aae75..632e9932cf 100644 --- a/tests/e2e.test.ts +++ b/tests/e2e.test.ts @@ -14,6 +14,7 @@ import { PredicateType, CredentialState, ProofState, + AutoAcceptCredential, } from '../src' import { getBaseConfig, @@ -29,7 +30,9 @@ import { SubjectInboundTransporter } from './transport/SubjectInboundTransport' import { SubjectOutboundTransporter } from './transport/SubjectOutboundTransport' import { WsInboundTransporter } from './transport/WsInboundTransport' -const recipientConfig = getBaseConfig('E2E Recipient') +const recipientConfig = getBaseConfig('E2E Recipient', { + autoAcceptCredentials: AutoAcceptCredential.ContentApproved, +}) const mediatorConfig = getBaseConfig('E2E Mediator', { endpoint: 'http://localhost:3002', autoAcceptMediationRequests: true, @@ -37,6 +40,7 @@ const mediatorConfig = getBaseConfig('E2E Mediator', { const senderConfig = getBaseConfig('E2E Sender', { endpoint: 'http://localhost:3003', mediatorPollingInterval: 1000, + autoAcceptCredentials: AutoAcceptCredential.ContentApproved, }) describe('E2E tests', () => {