diff --git a/README.md b/README.md index 940214d..baad90f 100644 --- a/README.md +++ b/README.md @@ -36,6 +36,7 @@ but likely needed for production and test deployments. | POSTGRES_ADMIN_PASSWORD | PosgreSQL database admin password | None | | REDIS_HOST | Redis database host user. This system will only function if this variable is defined. (Recommended for production mode) | None | | REDIS_PASSWORD | Redis database password | None | +| SUPPORTING_REVOCATION | Enables support for revocation features (true/false) | false | > **Note**: While not mandatory, it is recommended to set an agent public DID matching external hostname (e.g. if your Service Agent instance is accessable in `https://myagent.com:3000` you must set AGENT_PUBLIC_DID to `did:web:myagent.com%3A3000`), which will make possible for the agent to create its own creadential types and therefore issue credentials. Note that you'll need HTTPS in order to fully support did:web specification. diff --git a/doc/service-agent-api.md b/doc/service-agent-api.md index 04052b2..b676bc5 100644 --- a/doc/service-agent-api.md +++ b/doc/service-agent-api.md @@ -154,29 +154,28 @@ Parameters: - (optional) Revocation Index - (optional) Claims -> **Note:** When using revocation parameters (`revocationDefinitionId` and `revocationRegistryIndex`), it is essential to ensure the `id` was preserved from the time it was generated with the credential. The `revocationRegistryIndex` serves as a reference to the specific credential in the revocation registry. +> **Note:** When using revocation parameters (`revocationRegistryDefinitionId` and `revocationRegistryIndex`), it is essential to ensure the `id` was preserved from the time it was generated with the credential. The `revocationRegistryIndex` serves as a reference to the specific credential in the revocation registry. ```json { ... "type": "credential-issuance", "credentialDefinitionId": "id", - "revocationDefinitionId": "id", + "revocationRegistryDefinitionId": "id", "revocationRegistryIndex": 1, "claims": [{ "name": "claim-name", "mimeType": "mime-type", "value": "claim-value" }, ...] } ``` #### Credential Revocation -By sending this message, a Verifiable Credential is effectively revocated and sent to the destination connection. +By sending this message, a Verifiable Credential is effectively revoked and a notification is sent to the DIDComm connection it has been issued to. -This message could be sent as a response to a Credential issuance. In such case, `connectionId` and `revocationDefinitionId` is used to identify credential details. +This message could be sent as a credential revocation notification. In such case, `threadId` is used to identify credential details. ```json { ... "type": "credential-revocation", - "revocationDefinitionId": "id", } ``` diff --git a/examples/chatbot/data.ts b/examples/chatbot/data.ts index dd70776..9110fc6 100644 --- a/examples/chatbot/data.ts +++ b/examples/chatbot/data.ts @@ -23,10 +23,6 @@ export const rootContextMenu = { title: 'Issue credential', id: 'issue', }, - { - title: 'Revoke credential', - id: 'revoke', - }, { title: 'Request proof', id: 'proof', @@ -44,7 +40,6 @@ export const rootMenuAsQA = { { id: 'poll', text: '⚽ World Cup poll' }, { id: 'rocky', text: '💪 Rocky quotes' }, { id: 'issue', text: 'Issue credential' }, - { id: 'revoke', text: 'Revoke credential' }, { id: 'proof', text: 'Request proof' }, { id: 'help', text: '🆘 Help' }, ], diff --git a/examples/chatbot/index.ts b/examples/chatbot/index.ts index 5f02b50..2ea8f22 100644 --- a/examples/chatbot/index.ts +++ b/examples/chatbot/index.ts @@ -23,6 +23,7 @@ import { VerifiableCredentialSubmittedProofItem, MediaMessage, MrtdSubmitState, + CredentialReceptionMessage, } from '@2060.io/service-agent-model' import cors from 'cors' import { randomUUID } from 'crypto' @@ -99,9 +100,9 @@ const server = app.listen(PORT, async () => { phoneNumberCredentialDefinitionId = phoneNumberCredentialType?.id ?? credentialDefinition.id phoneNumberRevocationDefinitionId = - phoneNumberCredentialType?.revocationId ?? credentialDefinition.revocationId + phoneNumberCredentialType?.revocationId ? phoneNumberCredentialType?.revocationId?.[0] : credentialDefinition.revocationId?.[0] logger.info(`phoneNumberCredentialDefinitionId: ${phoneNumberCredentialDefinitionId}`) - logger.info(`phoneNumberRevocationDefinitionId: ${phoneNumberRevocationDefinitionId}`) + logger.info(`phoneNumberRevocationDefinitionId: ${credentialDefinition.revocationId}`) } catch (error) { logger.error(`Could not create or retrieve phone number credential type: ${error}`) } @@ -186,29 +187,13 @@ const handleMenuSelection = async (options: { connectionId: string; item: string value: '+5712345678', }, ], - revocationDefinitionId: phoneNumberRevocationDefinitionId, + revocationRegistryDefinitionId: phoneNumberRevocationDefinitionId, revocationRegistryIndex: phoneNumberRevocationCount += 1, }) await apiClient.messages.send(body) } } - if (selectedItem === 'revoke' || selectedItem === 'Revoke credential') { - if (!phoneNumberCredentialDefinitionId || phoneNumberCredentialDefinitionId === '' || - !phoneNumberRevocationDefinitionId || phoneNumberRevocationDefinitionId === '') { - await sendTextMessage({ - connectionId, - content: 'Service not available', - }) - } else { - const body = new CredentialRevocationMessage({ - connectionId, - revocationDefinitionId: phoneNumberRevocationDefinitionId, - }) - await apiClient.messages.send(body) - } - } - // Proof if (selectedItem === 'proof' || selectedItem === 'Request proof') { if (!phoneNumberCredentialDefinitionId || phoneNumberCredentialDefinitionId === '') { @@ -437,6 +422,22 @@ expressHandler.messageReceived(async (req, res) => { connectionId, }) await apiClient.messages.send(body) + } else if (content.startsWith('/revoke')) { + const parsedContents = content.split(' ') + let threadId = parsedContents[1] + if (!phoneNumberCredentialDefinitionId || phoneNumberCredentialDefinitionId === '' || + !phoneNumberRevocationDefinitionId || phoneNumberRevocationDefinitionId === '') { + await sendTextMessage({ + connectionId, + content: 'Service not available', + }) + } else { + const body = new CredentialRevocationMessage({ + connectionId, + threadId, + }) + await apiClient.messages.send(body) + } } else if (content.startsWith('/proof')) { const body = new IdentityProofRequestMessage({ connectionId, @@ -520,6 +521,12 @@ expressHandler.messageReceived(async (req, res) => { content: `Problem: emrtd ${obj.state}`, }) } + } else if (obj.type === CredentialReceptionMessage.type) { + await submitMessageReceipt(obj, 'viewed') + obj.state === 'done' && await sendTextMessage({ + connectionId: obj.connectionId, + content: `For revocation, please provide the thread ID: ${obj.threadId}`, + }) } }) diff --git a/packages/main/src/controllers/credentials/CredentialTypeController.ts b/packages/main/src/controllers/credentials/CredentialTypeController.ts index e93d7b5..fb41e98 100644 --- a/packages/main/src/controllers/credentials/CredentialTypeController.ts +++ b/packages/main/src/controllers/credentials/CredentialTypeController.ts @@ -38,8 +38,11 @@ import { CreateCredentialTypeDto } from './CredentialTypeDto' }) export class CredentialTypesController { private readonly logger = new Logger(CredentialTypesController.name) + private readonly supportRevocation: boolean - constructor(private readonly agentService: AgentService) {} + constructor(private readonly agentService: AgentService) { + this.supportRevocation = process.env.SUPPORTING_REVOCATION === 'true' + } /** * Get all created credential types @@ -57,13 +60,14 @@ export class CredentialTypesController { const schemaResult = await agent.modules.anoncreds.getSchema(record.credentialDefinition.schemaId) const schema = schemaResult.schema + const revocationRegistryIds = record.getTag('revocationRegistryDefinitionId') as string return { id: record.credentialDefinitionId, name: (record.getTag('name') as string) ?? schema?.name, version: (record.getTag('version') as string) ?? schema?.version, attributes: schema?.attrNames || [], - revocationId: record.getTag('revocationDefinitionId') as string, + revocationId: revocationRegistryIds ? revocationRegistryIds.split('::') : undefined, } }), ) @@ -133,7 +137,7 @@ export class CredentialTypesController { const registrationResult = await agent.modules.anoncreds.registerCredentialDefinition({ credentialDefinition: { issuerId, schemaId, tag: `${options.name}.${options.version}` }, - options: { supportRevocation: true }, + options: { supportRevocation: this.supportRevocation }, }) const credentialDefinitionId = registrationResult.credentialDefinitionState.credentialDefinitionId @@ -147,37 +151,53 @@ export class CredentialTypesController { ) } - const revocationResult = await agent.modules.anoncreds.registerRevocationRegistryDefinition({ - revocationRegistryDefinition: { - credentialDefinitionId, - tag: 'default', - maximumCredentialNumber: 1000, - issuerId, - }, - options: {}, - }) - const revocationDefinitionId = - revocationResult.revocationRegistryDefinitionState.revocationRegistryDefinitionId - this.logger.debug!( - `revocationRegistryDefinitionState: ${JSON.stringify(revocationResult.revocationRegistryDefinitionState)}`, - ) + let revocationRegistryDefinitionId + if (this.supportRevocation) { + const revocationResult = await agent.modules.anoncreds.registerRevocationRegistryDefinition({ + revocationRegistryDefinition: { + credentialDefinitionId, + tag: 'default', + maximumCredentialNumber: 1000, + issuerId, + }, + options: {}, + }) + revocationRegistryDefinitionId = + revocationResult.revocationRegistryDefinitionState.revocationRegistryDefinitionId + this.logger.debug!( + `revocationRegistryDefinitionState: ${JSON.stringify(revocationResult.revocationRegistryDefinitionState)}`, + ) - if (!revocationDefinitionId) { - throw new Error( - `Cannot create credential revocations: ${JSON.stringify(registrationResult.registrationMetadata)}`, + if (!revocationRegistryDefinitionId) { + throw new Error( + `Cannot create credential revocations: ${JSON.stringify(registrationResult.registrationMetadata)}`, + ) + } + + const revStatusListResult = await agent.modules.anoncreds.registerRevocationStatusList({ + revocationStatusList: { + issuerId, + revocationRegistryDefinitionId: revocationRegistryDefinitionId, + }, + options: {}, + }) + const revocationDefinitionRepository = agent.dependencyManager.resolve( + AnonCredsRevocationRegistryDefinitionRepository, + ) + const revocationDefinitionRecord = + await revocationDefinitionRepository.getByRevocationRegistryDefinitionId( + agent.context, + revocationRegistryDefinitionId, + ) + revocationDefinitionRecord.metadata.set( + 'revStatusList', + revStatusListResult.revocationStatusListState.revocationStatusList!, ) + await revocationDefinitionRepository.update(agent.context, revocationDefinitionRecord) } - const revStatusListResult = await agent.modules.anoncreds.registerRevocationStatusList({ - revocationStatusList: { - issuerId, - revocationRegistryDefinitionId: revocationDefinitionId, - }, - options: {}, - }) - this.logger.log(`Credential Definition Id: ${credentialDefinitionId}`) - this.logger.log(`Revocation Definition Id: ${revocationDefinitionId}`) + this.logger.log(`Revocation Registry Definition Id: ${revocationRegistryDefinitionId}`) // Apply name and version as tags const credentialDefinitionRepository = agent.dependencyManager.resolve( @@ -187,24 +207,11 @@ export class CredentialTypesController { agent.context, credentialDefinitionId, ) - const revocationDefinitionRepository = agent.dependencyManager.resolve( - AnonCredsRevocationRegistryDefinitionRepository, - ) - const revocationDefinitionRecord = - await revocationDefinitionRepository.getByRevocationRegistryDefinitionId( - agent.context, - revocationDefinitionId, - ) credentialDefinitionRecord.setTag('name', options.name) credentialDefinitionRecord.setTag('version', options.version) - credentialDefinitionRecord.setTag('revocationDefinitionId', revocationDefinitionId) + credentialDefinitionRecord.setTag('revocationRegistryDefinitionId', revocationRegistryDefinitionId) await credentialDefinitionRepository.update(agent.context, credentialDefinitionRecord) - revocationDefinitionRecord.metadata.set( - 'revStatusList', - revStatusListResult.revocationStatusListState.revocationStatusList!, - ) - await revocationDefinitionRepository.update(agent.context, revocationDefinitionRecord) return { id: credentialDefinitionId, @@ -212,7 +219,7 @@ export class CredentialTypesController { name: options.name, version: options.version, schemaId, - revocationId: revocationDefinitionId, + revocationId: revocationRegistryDefinitionId?.split('::'), } } catch (error) { throw new HttpException( diff --git a/packages/main/src/controllers/message/MessageService.ts b/packages/main/src/controllers/message/MessageService.ts index 21817b4..56a5c37 100644 --- a/packages/main/src/controllers/message/MessageService.ts +++ b/packages/main/src/controllers/message/MessageService.ts @@ -224,7 +224,7 @@ export class MessageService { return { name: item.name, mimeType: item.mimeType, value: item.value } }), credentialDefinitionId: msg.credentialDefinitionId, - revocationRegistryDefinitionId: msg.revocationDefinitionId, + revocationRegistryDefinitionId: msg.revocationRegistryDefinitionId, revocationRegistryIndex: msg.revocationRegistryIndex, }, }, @@ -241,17 +241,31 @@ export class MessageService { } else if (messageType === CredentialRevocationMessage.type) { const msg = JsonTransformer.fromJSON(message, CredentialRevocationMessage) - const credential = (await agent.credentials.getAll()).find( - item => - item.getTag('anonCredsRevocationRegistryId') === msg.revocationDefinitionId && - item.connectionId === msg.connectionId, - ) - if (credential) { - await agent.credentials.sendRevocationNotification({ - credentialRecordId: credential.id, - revocationFormat: 'anoncreds', - revocationId: `${credential.getTag('anonCredsRevocationRegistryId')}::${credential.getTag('anonCredsCredentialRevocationId')}`, - }) + const credentials = await agent.credentials.findAllByQuery({ threadId: msg.threadId }) + if (credentials && credentials.length > 0) { + for (const credential of credentials) { + const isRevoke = Boolean( + credential.getTag('anonCredsRevocationRegistryId') && + credential.getTag('anonCredsCredentialRevocationId'), + ) + isRevoke && + (await agent.modules.anoncreds.updateRevocationStatusList({ + revocationStatusList: { + revocationRegistryDefinitionId: credential.getTag( + 'anonCredsRevocationRegistryId', + ) as string, + revokedCredentialIndexes: [Number(credential.getTag('anonCredsCredentialRevocationId'))], + }, + options: {}, + })) + + isRevoke && + (await agent.credentials.sendRevocationNotification({ + credentialRecordId: credential.id, + revocationFormat: 'anoncreds', + revocationId: `${credential.getTag('anonCredsRevocationRegistryId')}::${credential.getTag('anonCredsCredentialRevocationId')}`, + })) + } } else { throw new Error(`No credentials were found for connection: ${msg.connectionId}.`) } diff --git a/packages/model/src/messages/CredentialIssuanceMessage.ts b/packages/model/src/messages/CredentialIssuanceMessage.ts index af9a209..3e467da 100644 --- a/packages/model/src/messages/CredentialIssuanceMessage.ts +++ b/packages/model/src/messages/CredentialIssuanceMessage.ts @@ -37,7 +37,7 @@ export class Claim { export interface CredentialIssuanceMessageOptions extends BaseMessageOptions { credentialDefinitionId: string - revocationDefinitionId?: string + revocationRegistryDefinitionId?: string revocationRegistryIndex?: number claims?: Claim[] } @@ -52,7 +52,7 @@ export class CredentialIssuanceMessage extends BaseMessage { this.timestamp = options.timestamp ?? new Date() this.connectionId = options.connectionId this.credentialDefinitionId = options.credentialDefinitionId - this.revocationDefinitionId = options.revocationDefinitionId + this.revocationRegistryDefinitionId = options.revocationRegistryDefinitionId this.revocationRegistryIndex = options.revocationRegistryIndex this.claims = options.claims?.map(item => new Claim(item)) } @@ -61,21 +61,17 @@ export class CredentialIssuanceMessage extends BaseMessage { public readonly type = CredentialIssuanceMessage.type public static readonly type = MessageType.CredentialIssuanceMessage - @Expose() @IsString() public credentialDefinitionId?: string - @Expose() @IsString() @IsOptional() - public revocationDefinitionId?: string + public revocationRegistryDefinitionId?: string - @Expose() @IsNumber() @IsOptional() public revocationRegistryIndex?: number - @Expose() @Type(() => Claim) @IsArray() @ValidateNested() diff --git a/packages/model/src/messages/CredentialRevocationMessage.ts b/packages/model/src/messages/CredentialRevocationMessage.ts index b4d6399..a0b15a7 100644 --- a/packages/model/src/messages/CredentialRevocationMessage.ts +++ b/packages/model/src/messages/CredentialRevocationMessage.ts @@ -1,12 +1,7 @@ -import { Expose } from 'class-transformer' -import { IsString } from 'class-validator' - import { BaseMessage, BaseMessageOptions } from './BaseMessage' import { MessageType } from './MessageType' -export interface CredentialRevocationMessageOptions extends BaseMessageOptions { - revocationDefinitionId: string -} +export interface CredentialRevocationMessageOptions extends BaseMessageOptions {} export class CredentialRevocationMessage extends BaseMessage { public constructor(options: CredentialRevocationMessageOptions) { @@ -17,14 +12,9 @@ export class CredentialRevocationMessage extends BaseMessage { this.threadId = options.threadId this.timestamp = options.timestamp ?? new Date() this.connectionId = options.connectionId - this.revocationDefinitionId = options.revocationDefinitionId } } public readonly type = CredentialRevocationMessage.type public static readonly type = MessageType.CredentialRevocationMessage - - @Expose() - @IsString() - public revocationDefinitionId!: string } diff --git a/packages/model/src/types.ts b/packages/model/src/types.ts index 9b9ecd9..c9b38be 100644 --- a/packages/model/src/types.ts +++ b/packages/model/src/types.ts @@ -27,7 +27,7 @@ export interface CreateCredentialTypeOptions { version: string attributes: string[] schemaId?: string - revocationId?: string + revocationId?: string[] } type JsonObject = {