diff --git a/README.md b/README.md index 338847620f..e99cce1b80 100644 --- a/README.md +++ b/README.md @@ -137,9 +137,10 @@ Also check out [Aries Framework JavaScript Extensions](https://github.com/hyperl Although Aries Framework JavaScript tries to follow the standards as described in the Aries RFCs as much as possible, some features in AFJ slightly diverge from the written spec. Below is an overview of the features that diverge from the spec, their impact and the reasons for diverging. -| Feature | Impact | Reason | -| -------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | -| Support for `imageUrl` attribute in connection invitation and connection request | Properties that are not recognized should be ignored, meaning this shouldn't limit interoperability between agents. As the image url is self-attested it could give a false sense of trust. Better, credential based, method for visually identifying an entity are not present yet. | Even though not documented, almost all agents support this feature. Not including this feature means AFJ is lacking in features in comparison to other implementations. | +| Feature | Impact | Reason | +| -------------------------------------------------------------------------------------------------------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- | ----------------------------------------------------------------------------------------------------------------------------------------------------------------------- | +| Support for `imageUrl` attribute in connection invitation and connection request | Properties that are not recognized should be ignored, meaning this shouldn't limit interoperability between agents. As the image url is self-attested it could give a false sense of trust. Better, credential based, method for visually identifying an entity are not present yet. | Even though not documented, almost all agents support this feature. Not including this feature means AFJ is lacking in features in comparison to other implementations. | +| Revocation Notification v1 uses a different `thread_id` format ( `indy::::`) than specified in the Aries RFC | Any agents adhering to the [revocation notification v1 RFC](https://github.com/hyperledger/aries-rfcs/tree/main/features/0183-revocation-notification) will not be interoperable with Aries Framework Javascript. However, revocation notification is considered an optional portion of revocation, therefore this will not break core revocation behavior. Ideally agents should use and implement revocation notification v2. | Actual implementations (ACA-Py) of revocation notification v1 so far have implemented this different format, so this format change was made to remain interoperable. | ## Contributing diff --git a/packages/core/src/modules/credentials/CredentialEvents.ts b/packages/core/src/modules/credentials/CredentialEvents.ts index 1a43613f7e..29a136a14b 100644 --- a/packages/core/src/modules/credentials/CredentialEvents.ts +++ b/packages/core/src/modules/credentials/CredentialEvents.ts @@ -4,6 +4,7 @@ import type { CredentialRecord } from './repository/CredentialRecord' export enum CredentialEventTypes { CredentialStateChanged = 'CredentialStateChanged', + RevocationNotificationReceived = 'RevocationNotificationReceived', } export interface CredentialStateChangedEvent extends BaseEvent { type: typeof CredentialEventTypes.CredentialStateChanged @@ -12,3 +13,10 @@ export interface CredentialStateChangedEvent extends BaseEvent { previousState: CredentialState | null } } + +export interface RevocationNotificationReceivedEvent extends BaseEvent { + type: typeof CredentialEventTypes.RevocationNotificationReceived + payload: { + credentialRecord: CredentialRecord + } +} diff --git a/packages/core/src/modules/credentials/CredentialsModule.ts b/packages/core/src/modules/credentials/CredentialsModule.ts index 3823a42f8e..bc18c03b0b 100644 --- a/packages/core/src/modules/credentials/CredentialsModule.ts +++ b/packages/core/src/modules/credentials/CredentialsModule.ts @@ -23,10 +23,12 @@ import { OfferCredentialHandler, ProposeCredentialHandler, RequestCredentialHandler, + V1RevocationNotificationHandler, + V2RevocationNotificationHandler, CredentialProblemReportHandler, } from './handlers' import { CredentialProblemReportMessage } from './messages' -import { CredentialService } from './services' +import { CredentialService, RevocationService } from './services' @scoped(Lifecycle.ContainerScoped) export class CredentialsModule { @@ -36,6 +38,7 @@ export class CredentialsModule { private agentConfig: AgentConfig private credentialResponseCoordinator: CredentialResponseCoordinator private mediationRecipientService: MediationRecipientService + private revocationService: RevocationService public constructor( dispatcher: Dispatcher, @@ -44,7 +47,8 @@ export class CredentialsModule { messageSender: MessageSender, agentConfig: AgentConfig, credentialResponseCoordinator: CredentialResponseCoordinator, - mediationRecipientService: MediationRecipientService + mediationRecipientService: MediationRecipientService, + revocationService: RevocationService ) { this.connectionService = connectionService this.credentialService = credentialService @@ -52,6 +56,7 @@ export class CredentialsModule { this.agentConfig = agentConfig this.credentialResponseCoordinator = credentialResponseCoordinator this.mediationRecipientService = mediationRecipientService + this.revocationService = revocationService this.registerHandlers(dispatcher) } @@ -530,6 +535,8 @@ export class CredentialsModule { new IssueCredentialHandler(this.credentialService, this.agentConfig, this.credentialResponseCoordinator) ) dispatcher.registerHandler(new CredentialAckHandler(this.credentialService)) + dispatcher.registerHandler(new V1RevocationNotificationHandler(this.revocationService)) + dispatcher.registerHandler(new V2RevocationNotificationHandler(this.revocationService)) dispatcher.registerHandler(new CredentialProblemReportHandler(this.credentialService)) } } diff --git a/packages/core/src/modules/credentials/__tests__/CredentialService.test.ts b/packages/core/src/modules/credentials/__tests__/CredentialService.test.ts index a2948fcdd0..c1a87438bf 100644 --- a/packages/core/src/modules/credentials/__tests__/CredentialService.test.ts +++ b/packages/core/src/modules/credentials/__tests__/CredentialService.test.ts @@ -1,6 +1,8 @@ +import type { Logger } from '../../../logger' +import type { ConnectionRecord } from '../../connections' import type { ConnectionService } from '../../connections/services/ConnectionService' import type { StoreCredentialOptions } from '../../indy/services/IndyHolderService' -import type { CredentialStateChangedEvent } from '../CredentialEvents' +import type { RevocationNotificationReceivedEvent, CredentialStateChangedEvent } from '../CredentialEvents' import type { CredentialPreviewAttribute } from '../messages' import type { IndyCredentialMetadata } from '../models/CredentialInfo' import type { CustomCredentialTags } from '../repository/CredentialRecord' @@ -10,7 +12,7 @@ import { getAgentConfig, getMockConnection, mockFunction } from '../../../../tes import { EventEmitter } from '../../../agent/EventEmitter' import { InboundMessageContext } from '../../../agent/models/InboundMessageContext' import { Attachment, AttachmentData } from '../../../decorators/attachment/Attachment' -import { RecordNotFoundError } from '../../../error' +import { AriesFrameworkError, RecordNotFoundError } from '../../../error' import { JsonEncoder } from '../../../utils/JsonEncoder' import { AckStatus } from '../../common' import { ConnectionState } from '../../connections' @@ -22,6 +24,8 @@ import { CredentialState } from '../CredentialState' import { CredentialUtils } from '../CredentialUtils' import { CredentialProblemReportReason } from '../errors/CredentialProblemReportReason' import { + V2RevocationNotificationMessage, + V1RevocationNotificationMessage, CredentialAckMessage, CredentialPreview, INDY_CREDENTIAL_ATTACHMENT_ID, @@ -34,7 +38,7 @@ import { import { CredentialRecord } from '../repository/CredentialRecord' import { CredentialRepository } from '../repository/CredentialRepository' import { CredentialMetadataKeys } from '../repository/credentialMetadataTypes' -import { CredentialService } from '../services' +import { CredentialService, RevocationService } from '../services' import { CredentialProblemReportMessage } from './../messages/CredentialProblemReportMessage' import { credDef, credOffer, credReq, schema } from './fixtures' @@ -100,6 +104,8 @@ const mockCredentialRecord = ({ tags, id, credentialAttributes, + indyRevocationRegistryId, + indyCredentialRevocationId, }: { state?: CredentialState requestMessage?: RequestCredentialMessage @@ -110,6 +116,8 @@ const mockCredentialRecord = ({ credentialId?: string id?: string credentialAttributes?: CredentialPreviewAttribute[] + indyRevocationRegistryId?: string + indyCredentialRevocationId?: string } = {}) => { const offerMessage = new OfferCredentialMessage({ comment: 'some comment', @@ -145,16 +153,23 @@ const mockCredentialRecord = ({ }) } + credentialRecord.metadata.add(CredentialMetadataKeys.IndyCredential, { + indyCredentialRevocationId, + indyRevocationRegistryId, + }) + return credentialRecord } describe('CredentialService', () => { let credentialRepository: CredentialRepository let credentialService: CredentialService + let revocationService: RevocationService let ledgerService: IndyLedgerService let indyIssuerService: IndyIssuerService let indyHolderService: IndyHolderService let eventEmitter: EventEmitter + let logger: Logger beforeEach(() => { const agentConfig = getAgentConfig('CredentialServiceTest') @@ -163,6 +178,7 @@ describe('CredentialService', () => { indyHolderService = new IndyHolderServiceMock() ledgerService = new IndyLedgerServiceMock() eventEmitter = new EventEmitter(agentConfig) + logger = agentConfig.logger credentialService = new CredentialService( credentialRepository, @@ -177,6 +193,8 @@ describe('CredentialService', () => { eventEmitter ) + revocationService = new RevocationService(credentialRepository, eventEmitter, agentConfig) + mockFunction(ledgerService.getCredentialDefinition).mockReturnValue(Promise.resolve(credDef)) mockFunction(ledgerService.getSchema).mockReturnValue(Promise.resolve(schema)) }) @@ -1175,4 +1193,249 @@ describe('CredentialService', () => { ) }) }) + + describe('revocationNotification', () => { + let credential: CredentialRecord + + beforeEach(() => { + credential = mockCredentialRecord({ + state: CredentialState.Done, + indyRevocationRegistryId: + 'AsB27X6KRrJFsqZ3unNAH6:4:AsB27X6KRrJFsqZ3unNAH6:3:cl:48187:default:CL_ACCUM:3b24a9b0-a979-41e0-9964-2292f2b1b7e9', + indyCredentialRevocationId: '1', + connectionId: connection.id, + }) + }) + + test('Test revocation notification event being emitted for V1', async () => { + const eventListenerMock = jest.fn() + eventEmitter.on( + CredentialEventTypes.RevocationNotificationReceived, + eventListenerMock + ) + const date = new Date(2022) + + mockFunction(credentialRepository.getSingleByQuery).mockReturnValueOnce(Promise.resolve(credential)) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const spy = jest.spyOn(global, 'Date').mockImplementation(() => date) + + const { indyRevocationRegistryId, indyCredentialRevocationId } = credential.getTags() + const revocationNotificationThreadId = `indy::${indyRevocationRegistryId}::${indyCredentialRevocationId}` + + const revocationNotificationMessage = new V1RevocationNotificationMessage({ + issueThread: revocationNotificationThreadId, + comment: 'Credential has been revoked', + }) + const messageContext = new InboundMessageContext(revocationNotificationMessage, { + connection, + }) + + await revocationService.v1ProcessRevocationNotification(messageContext) + + expect(eventListenerMock).toHaveBeenCalledWith({ + type: 'RevocationNotificationReceived', + payload: { + credentialRecord: { + ...credential, + revocationNotification: { + revocationDate: date, + comment: 'Credential has been revoked', + }, + }, + }, + }) + + spy.mockRestore() + }) + + test('Error is logged when no matching credential found for revocation notification V1', async () => { + const loggerSpy = jest.spyOn(logger, 'warn') + + const revocationRegistryId = + 'ABC12D3EFgHIjKL4mnOPQ5:4:AsB27X6KRrJFsqZ3unNAH6:3:cl:48187:default:CL_ACCUM:3b24a9b0-a979-41e0-9964-2292f2b1b7e9' + const credentialRevocationId = '2' + const revocationNotificationThreadId = `indy::${revocationRegistryId}::${credentialRevocationId}` + const recordNotFoundError = new RecordNotFoundError( + `No record found for given query '${JSON.stringify({ revocationRegistryId, credentialRevocationId })}'`, + { + recordType: CredentialRecord.type, + } + ) + + mockFunction(credentialRepository.getSingleByQuery).mockReturnValue(Promise.reject(recordNotFoundError)) + + const revocationNotificationMessage = new V1RevocationNotificationMessage({ + issueThread: revocationNotificationThreadId, + comment: 'Credential has been revoked', + }) + const messageContext = new InboundMessageContext(revocationNotificationMessage, { connection }) + + await revocationService.v1ProcessRevocationNotification(messageContext) + + expect(loggerSpy).toBeCalledWith('Failed to process revocation notification message', { + error: recordNotFoundError, + threadId: revocationNotificationThreadId, + }) + }) + + test('Error is logged when invalid threadId is passed for revocation notification V1', async () => { + const loggerSpy = jest.spyOn(logger, 'warn') + + const revocationNotificationThreadId = 'notIndy::invalidRevRegId::invalidCredRevId' + const invalidThreadFormatError = new AriesFrameworkError( + `Incorrect revocation notification threadId format: \n${revocationNotificationThreadId}\ndoes not match\n"indy::::"` + ) + + const revocationNotificationMessage = new V1RevocationNotificationMessage({ + issueThread: revocationNotificationThreadId, + comment: 'Credential has been revoked', + }) + const messageContext = new InboundMessageContext(revocationNotificationMessage) + + await revocationService.v1ProcessRevocationNotification(messageContext) + + expect(loggerSpy).toBeCalledWith('Failed to process revocation notification message', { + error: invalidThreadFormatError, + threadId: revocationNotificationThreadId, + }) + }) + + test('Test revocation notification event being emitted for V2', async () => { + const eventListenerMock = jest.fn() + eventEmitter.on( + CredentialEventTypes.RevocationNotificationReceived, + eventListenerMock + ) + const date = new Date(2022) + + mockFunction(credentialRepository.getSingleByQuery).mockReturnValueOnce(Promise.resolve(credential)) + + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const spy = jest.spyOn(global, 'Date').mockImplementation(() => date) + + const { indyRevocationRegistryId, indyCredentialRevocationId } = credential.getTags() + const revocationNotificationCredentialId = `${indyRevocationRegistryId}::${indyCredentialRevocationId}` + + const revocationNotificationMessage = new V2RevocationNotificationMessage({ + credentialId: revocationNotificationCredentialId, + revocationFormat: 'indy', + comment: 'Credential has been revoked', + }) + const messageContext = new InboundMessageContext(revocationNotificationMessage, { + connection, + }) + + await revocationService.v2ProcessRevocationNotification(messageContext) + + expect(eventListenerMock).toHaveBeenCalledWith({ + type: 'RevocationNotificationReceived', + payload: { + credentialRecord: { + ...credential, + revocationNotification: { + revocationDate: date, + comment: 'Credential has been revoked', + }, + }, + }, + }) + + spy.mockRestore() + }) + + test('Error is logged when no matching credential found for revocation notification V2', async () => { + const loggerSpy = jest.spyOn(logger, 'warn') + + const revocationRegistryId = + 'ABC12D3EFgHIjKL4mnOPQ5:4:AsB27X6KRrJFsqZ3unNAH6:3:cl:48187:default:CL_ACCUM:3b24a9b0-a979-41e0-9964-2292f2b1b7e9' + const credentialRevocationId = '2' + const credentialId = `${revocationRegistryId}::${credentialRevocationId}` + + const recordNotFoundError = new RecordNotFoundError( + `No record found for given query '${JSON.stringify({ revocationRegistryId, credentialRevocationId })}'`, + { + recordType: CredentialRecord.type, + } + ) + + mockFunction(credentialRepository.getSingleByQuery).mockReturnValue(Promise.reject(recordNotFoundError)) + + const revocationNotificationMessage = new V2RevocationNotificationMessage({ + credentialId, + revocationFormat: 'indy', + comment: 'Credential has been revoked', + }) + const messageContext = new InboundMessageContext(revocationNotificationMessage, { connection }) + + await revocationService.v2ProcessRevocationNotification(messageContext) + + expect(loggerSpy).toBeCalledWith('Failed to process revocation notification message', { + error: recordNotFoundError, + credentialId, + }) + }) + + test('Error is logged when invalid credentialId is passed for revocation notification V2', async () => { + const loggerSpy = jest.spyOn(logger, 'warn') + + const invalidCredentialId = 'notIndy::invalidRevRegId::invalidCredRevId' + const invalidFormatError = new AriesFrameworkError( + `Incorrect revocation notification credentialId format: \n${invalidCredentialId}\ndoes not match\n"::"` + ) + + const revocationNotificationMessage = new V2RevocationNotificationMessage({ + credentialId: invalidCredentialId, + revocationFormat: 'indy', + comment: 'Credenti1al has been revoked', + }) + const messageContext = new InboundMessageContext(revocationNotificationMessage) + + await revocationService.v2ProcessRevocationNotification(messageContext) + + expect(loggerSpy).toBeCalledWith('Failed to process revocation notification message', { + error: invalidFormatError, + credentialId: invalidCredentialId, + }) + }) + + test('Test error being thrown when connection does not match issuer', async () => { + const loggerSpy = jest.spyOn(logger, 'warn') + const date = new Date(2022) + + const error = new AriesFrameworkError( + "Credential record is associated with connection '123'. Current connection is 'fd9c5ddb-ec11-4acd-bc32-540736249746'" + ) + + mockFunction(credentialRepository.getSingleByQuery).mockReturnValueOnce(Promise.resolve(credential)) + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + const spy = jest.spyOn(global, 'Date').mockImplementation(() => date) + + const { indyRevocationRegistryId, indyCredentialRevocationId } = credential.getTags() + const revocationNotificationThreadId = `indy::${indyRevocationRegistryId}::${indyCredentialRevocationId}` + + const revocationNotificationMessage = new V1RevocationNotificationMessage({ + issueThread: revocationNotificationThreadId, + comment: 'Credential has been revoked', + }) + const messageContext = new InboundMessageContext(revocationNotificationMessage, { + connection: { + id: 'fd9c5ddb-ec11-4acd-bc32-540736249746', + // eslint-disable-next-line @typescript-eslint/no-empty-function + assertReady: () => {}, + } as ConnectionRecord, + }) + + await revocationService.v1ProcessRevocationNotification(messageContext) + + expect(loggerSpy).toBeCalledWith('Failed to process revocation notification message', { + error, + threadId: revocationNotificationThreadId, + }) + + spy.mockRestore() + }) + }) }) diff --git a/packages/core/src/modules/credentials/handlers/RevocationNotificationHandler.ts b/packages/core/src/modules/credentials/handlers/RevocationNotificationHandler.ts new file mode 100644 index 0000000000..799a43b3e2 --- /dev/null +++ b/packages/core/src/modules/credentials/handlers/RevocationNotificationHandler.ts @@ -0,0 +1,30 @@ +import type { Handler, HandlerInboundMessage } from '../../../agent/Handler' +import type { RevocationService } from '../services' + +import { V1RevocationNotificationMessage, V2RevocationNotificationMessage } from '../messages' + +export class V1RevocationNotificationHandler implements Handler { + private revocationService: RevocationService + public supportedMessages = [V1RevocationNotificationMessage] + + public constructor(revocationService: RevocationService) { + this.revocationService = revocationService + } + + public async handle(messageContext: HandlerInboundMessage) { + await this.revocationService.v1ProcessRevocationNotification(messageContext) + } +} + +export class V2RevocationNotificationHandler implements Handler { + private revocationService: RevocationService + public supportedMessages = [V2RevocationNotificationMessage] + + public constructor(revocationService: RevocationService) { + this.revocationService = revocationService + } + + public async handle(messageContext: HandlerInboundMessage) { + await this.revocationService.v2ProcessRevocationNotification(messageContext) + } +} diff --git a/packages/core/src/modules/credentials/handlers/index.ts b/packages/core/src/modules/credentials/handlers/index.ts index 6f732d6413..1516216c29 100644 --- a/packages/core/src/modules/credentials/handlers/index.ts +++ b/packages/core/src/modules/credentials/handlers/index.ts @@ -3,4 +3,5 @@ export * from './IssueCredentialHandler' export * from './OfferCredentialHandler' export * from './ProposeCredentialHandler' export * from './RequestCredentialHandler' +export * from './RevocationNotificationHandler' export * from './CredentialProblemReportHandler' diff --git a/packages/core/src/modules/credentials/messages/RevocationNotificationMessage.ts b/packages/core/src/modules/credentials/messages/RevocationNotificationMessage.ts new file mode 100644 index 0000000000..7e41a2eba3 --- /dev/null +++ b/packages/core/src/modules/credentials/messages/RevocationNotificationMessage.ts @@ -0,0 +1,74 @@ +import type { AckDecorator } from '../../../decorators/ack/AckDecorator' + +import { Expose } from 'class-transformer' +import { Equals, IsOptional, IsString } from 'class-validator' + +import { AgentMessage } from '../../../agent/AgentMessage' + +export interface RevocationNotificationMessageV1Options { + issueThread: string + id?: string + comment?: string + pleaseAck?: AckDecorator +} + +export class V1RevocationNotificationMessage extends AgentMessage { + public constructor(options: RevocationNotificationMessageV1Options) { + super() + if (options) { + this.issueThread = options.issueThread + this.id = options.id ?? this.generateId() + this.comment = options.comment + this.pleaseAck = options.pleaseAck + } + } + + @Equals(V1RevocationNotificationMessage.type) + public readonly type = V1RevocationNotificationMessage.type + public static readonly type = 'https://didcomm.org/revocation_notification/1.0/revoke' + + @IsString() + @IsOptional() + public comment?: string + + @Expose({ name: 'thread_id' }) + @IsString() + public issueThread!: string +} + +export interface RevocationNotificationMessageV2Options { + revocationFormat: string + credentialId: string + id?: string + comment?: string + pleaseAck?: AckDecorator +} + +export class V2RevocationNotificationMessage extends AgentMessage { + public constructor(options: RevocationNotificationMessageV2Options) { + super() + if (options) { + this.revocationFormat = options.revocationFormat + this.credentialId = options.credentialId + this.id = options.id ?? this.generateId() + this.comment = options.comment + this.pleaseAck = options.pleaseAck + } + } + + @Equals(V2RevocationNotificationMessage.type) + public readonly type = V2RevocationNotificationMessage.type + public static readonly type = 'https://didcomm.org/revocation_notification/2.0/revoke' + + @IsString() + @IsOptional() + public comment?: string + + @Expose({ name: 'revocation_format' }) + @IsString() + public revocationFormat!: string + + @Expose({ name: 'credential_id' }) + @IsString() + public credentialId!: string +} diff --git a/packages/core/src/modules/credentials/messages/index.ts b/packages/core/src/modules/credentials/messages/index.ts index 60e1acf335..0b78a2d4a1 100644 --- a/packages/core/src/modules/credentials/messages/index.ts +++ b/packages/core/src/modules/credentials/messages/index.ts @@ -4,4 +4,5 @@ export * from './RequestCredentialMessage' export * from './IssueCredentialMessage' export * from './OfferCredentialMessage' export * from './ProposeCredentialMessage' +export * from './RevocationNotificationMessage' export * from './CredentialProblemReportMessage' diff --git a/packages/core/src/modules/credentials/models/RevocationNotification.ts b/packages/core/src/modules/credentials/models/RevocationNotification.ts new file mode 100644 index 0000000000..b26a52c7ad --- /dev/null +++ b/packages/core/src/modules/credentials/models/RevocationNotification.ts @@ -0,0 +1,9 @@ +export class RevocationNotification { + public revocationDate: Date + public comment?: string + + public constructor(comment?: string, revocationDate: Date = new Date()) { + this.revocationDate = revocationDate + this.comment = comment + } +} diff --git a/packages/core/src/modules/credentials/models/index.ts b/packages/core/src/modules/credentials/models/index.ts index cae218929d..9e47b2ca8d 100644 --- a/packages/core/src/modules/credentials/models/index.ts +++ b/packages/core/src/modules/credentials/models/index.ts @@ -1,3 +1,4 @@ export * from './Credential' export * from './IndyCredentialInfo' export * from './RevocationInterval' +export * from './RevocationNotification' diff --git a/packages/core/src/modules/credentials/repository/CredentialRecord.ts b/packages/core/src/modules/credentials/repository/CredentialRecord.ts index 108183296b..53614d5960 100644 --- a/packages/core/src/modules/credentials/repository/CredentialRecord.ts +++ b/packages/core/src/modules/credentials/repository/CredentialRecord.ts @@ -1,6 +1,7 @@ import type { TagsBase } from '../../../storage/BaseRecord' import type { AutoAcceptCredential } from '../CredentialAutoAcceptType' import type { CredentialState } from '../CredentialState' +import type { RevocationNotification } from '../models/' import type { CredentialMetadata } from './credentialMetadataTypes' import { Type } from 'class-transformer' @@ -18,6 +19,8 @@ import { } from '../messages' import { CredentialInfo } from '../models/CredentialInfo' +import { CredentialMetadataKeys } from './credentialMetadataTypes' + export interface CredentialRecordProps { id?: string createdAt?: Date @@ -34,6 +37,7 @@ export interface CredentialRecordProps { credentialAttributes?: CredentialPreviewAttribute[] autoAcceptCredential?: AutoAcceptCredential linkedAttachments?: Attachment[] + revocationNotification?: RevocationNotification errorMessage?: string } @@ -43,6 +47,8 @@ export type DefaultCredentialTags = { connectionId?: string state: CredentialState credentialId?: string + indyRevocationRegistryId?: string + indyCredentialRevocationId?: string } export class CredentialRecord extends BaseRecord { @@ -51,6 +57,7 @@ export class CredentialRecord extends BaseRecord({ + type: CredentialEventTypes.RevocationNotificationReceived, + payload: { + credentialRecord, + }, + }) + } + + /** + * Process a recieved {@link V1RevocationNotificationMessage}. This will create a + * {@link RevocationNotification} and store it in the corresponding {@link CredentialRecord} + * + * @param messageContext message context of RevocationNotificationMessageV1 + */ + public async v1ProcessRevocationNotification( + messageContext: InboundMessageContext + ): Promise { + this.logger.info('Processing revocation notification v1', { message: messageContext.message }) + // ThreadID = indy:::: + const threadRegex = + /(indy)::((?:[\dA-z]{21,22}):4:(?:[\dA-z]{21,22}):3:[Cc][Ll]:(?:(?:[1-9][0-9]*)|(?:[\dA-z]{21,22}:2:.+:[0-9.]+))(?::[\dA-z]+)?:CL_ACCUM:(?:[\dA-z-]+))::(\d+)$/ + const threadId = messageContext.message.issueThread + try { + const threadIdGroups = threadId.match(threadRegex) + if (threadIdGroups) { + const [, , indyRevocationRegistryId, indyCredentialRevocationId] = threadIdGroups + const comment = messageContext.message.comment + const connection = messageContext.assertReadyConnection() + await this.processRevocationNotification( + indyRevocationRegistryId, + indyCredentialRevocationId, + connection, + comment + ) + } else { + throw new AriesFrameworkError( + `Incorrect revocation notification threadId format: \n${threadId}\ndoes not match\n"indy::::"` + ) + } + } catch (error) { + this.logger.warn('Failed to process revocation notification message', { error, threadId }) + } + } + + /** + * Process a recieved {@link V2RevocationNotificationMessage}. This will create a + * {@link RevocationNotification} and store it in the corresponding {@link CredentialRecord} + * + * @param messageContext message context of RevocationNotificationMessageV2 + */ + public async v2ProcessRevocationNotification( + messageContext: InboundMessageContext + ): Promise { + this.logger.info('Processing revocation notification v2', { message: messageContext.message }) + // CredentialId = :: + const credentialIdRegex = + /((?:[\dA-z]{21,22}):4:(?:[\dA-z]{21,22}):3:[Cc][Ll]:(?:(?:[1-9][0-9]*)|(?:[\dA-z]{21,22}:2:.+:[0-9.]+))(?::[\dA-z]+)?:CL_ACCUM:(?:[\dA-z-]+))::(\d+)$/ + const credentialId = messageContext.message.credentialId + try { + const credentialIdGroups = credentialId.match(credentialIdRegex) + if (credentialIdGroups) { + const [, indyRevocationRegistryId, indyCredentialRevocationId] = credentialIdGroups + const comment = messageContext.message.comment + const connection = messageContext.assertReadyConnection() + await this.processRevocationNotification( + indyRevocationRegistryId, + indyCredentialRevocationId, + connection, + comment + ) + } else { + throw new AriesFrameworkError( + `Incorrect revocation notification credentialId format: \n${credentialId}\ndoes not match\n"::"` + ) + } + } catch (error) { + this.logger.warn('Failed to process revocation notification message', { error, credentialId }) + } + } +} diff --git a/packages/core/src/modules/credentials/services/index.ts b/packages/core/src/modules/credentials/services/index.ts index 3ef45ad8eb..0c38ea1a3c 100644 --- a/packages/core/src/modules/credentials/services/index.ts +++ b/packages/core/src/modules/credentials/services/index.ts @@ -1 +1,2 @@ export * from './CredentialService' +export * from './RevocationService' diff --git a/packages/core/src/modules/vc/__tests__/W3cCredentialService.test.ts b/packages/core/src/modules/vc/__tests__/W3cCredentialService.test.ts index 2aa47c7aa5..2531135560 100644 --- a/packages/core/src/modules/vc/__tests__/W3cCredentialService.test.ts +++ b/packages/core/src/modules/vc/__tests__/W3cCredentialService.test.ts @@ -414,23 +414,5 @@ describe('W3cCredentialService', () => { expect(verifiablePresentation).toBeInstanceOf(W3cVerifiablePresentation) }) }) - describe('verifyPresentation', () => { - it('should verify the presentation successfully', async () => { - const vp = JsonTransformer.fromJSON( - BbsBlsSignature2020Fixtures.TEST_VP_DOCUMENT_SIGNED, - W3cVerifiablePresentation - ) - - const result = await w3cCredentialService.verifyPresentation({ - presentation: vp, - proofType: 'Ed25519Signature2018', - challenge: 'c449df71-0eb5-4e2d-84cb-d6cd680579bd', - verificationMethod: - 'did:key:z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL#z6Mkgg342Ycpuk263R9d8Aq6MUaxPn1DDeHyGo38EefXmgDL', - }) - expect(result.verified).toBe(true) - }) - }) - xdescribe('storeCredential', () => {}) }) }) diff --git a/packages/core/src/storage/migration/__tests__/__snapshots__/0.1.test.ts.snap b/packages/core/src/storage/migration/__tests__/__snapshots__/0.1.test.ts.snap index 2b9eacac2b..3d0d23f77c 100644 --- a/packages/core/src/storage/migration/__tests__/__snapshots__/0.1.test.ts.snap +++ b/packages/core/src/storage/migration/__tests__/__snapshots__/0.1.test.ts.snap @@ -7,6 +7,8 @@ Object { "tags": Object { "connectionId": "0b6de73d-b376-430f-b2b4-f6e51407bb66", "credentialId": undefined, + "indyCredentialRevocationId": undefined, + "indyRevocationRegistryId": undefined, "state": "done", "threadId": "578e73da-c3be-43d4-949b-7aadfd5a6eae", }, @@ -144,6 +146,8 @@ Object { "tags": Object { "connectionId": "54b61a2c-59ae-4e63-a441-7f1286350132", "credentialId": "a77114e1-c812-4bff-a53c-3d5003fcc278", + "indyCredentialRevocationId": undefined, + "indyRevocationRegistryId": undefined, "state": "done", "threadId": "578e73da-c3be-43d4-949b-7aadfd5a6eae", }, @@ -301,6 +305,8 @@ Object { "tags": Object { "connectionId": "cd66cbf1-5721-449e-8724-f4d8dcef1bc4", "credentialId": undefined, + "indyCredentialRevocationId": undefined, + "indyRevocationRegistryId": undefined, "state": "done", "threadId": "e2c2194c-6ac6-4b27-9030-18887c79b5eb", }, @@ -438,6 +444,8 @@ Object { "tags": Object { "connectionId": "d8f23338-9e99-469a-bd57-1c9a26c0080f", "credentialId": "19c1f29f-d2df-486c-b8c6-950c403fa7d9", + "indyCredentialRevocationId": undefined, + "indyRevocationRegistryId": undefined, "state": "done", "threadId": "e2c2194c-6ac6-4b27-9030-18887c79b5eb", }, @@ -589,6 +597,8 @@ Object { "tags": Object { "connectionId": "0b6de73d-b376-430f-b2b4-f6e51407bb66", "credentialId": undefined, + "indyCredentialRevocationId": undefined, + "indyRevocationRegistryId": undefined, "state": "done", "threadId": "578e73da-c3be-43d4-949b-7aadfd5a6eae", }, @@ -726,6 +736,8 @@ Object { "tags": Object { "connectionId": "54b61a2c-59ae-4e63-a441-7f1286350132", "credentialId": "a77114e1-c812-4bff-a53c-3d5003fcc278", + "indyCredentialRevocationId": undefined, + "indyRevocationRegistryId": undefined, "state": "done", "threadId": "578e73da-c3be-43d4-949b-7aadfd5a6eae", }, @@ -883,6 +895,8 @@ Object { "tags": Object { "connectionId": "cd66cbf1-5721-449e-8724-f4d8dcef1bc4", "credentialId": undefined, + "indyCredentialRevocationId": undefined, + "indyRevocationRegistryId": undefined, "state": "done", "threadId": "e2c2194c-6ac6-4b27-9030-18887c79b5eb", }, @@ -1020,6 +1034,8 @@ Object { "tags": Object { "connectionId": "d8f23338-9e99-469a-bd57-1c9a26c0080f", "credentialId": "19c1f29f-d2df-486c-b8c6-950c403fa7d9", + "indyCredentialRevocationId": undefined, + "indyRevocationRegistryId": undefined, "state": "done", "threadId": "e2c2194c-6ac6-4b27-9030-18887c79b5eb", },