Skip to content

Commit

Permalink
feat: Credential Revocation Support and Related Services (#57)
Browse files Browse the repository at this point in the history
  • Loading branch information
lotharking authored Dec 19, 2024
1 parent 40f71a7 commit d8d34df
Show file tree
Hide file tree
Showing 23 changed files with 628 additions and 14 deletions.
21 changes: 19 additions & 2 deletions doc/service-agent-api.md
Original file line number Diff line number Diff line change
Expand Up @@ -149,16 +149,33 @@ By sending this message, a Verifiable Credential is effectively issued and sent
This message could be sent as a response to a Credential Request. In such case, `threadId` is used to identify credential details. But it can also start a new Credential Issuance flow, and specify

Parameters:

- (optional) Credential Definition ID
- (optional) Revocation Definition ID
- (optional) Revocation Index
- (optional) Claims

**Note:** When using revocation parameters (`revocationRegistryDefinitionId` and `revocationRegistryIndex`), it is crucial to preserve both values as they were originally generated with the credential. Each revocation registry has a finite capacity for credentials (default is 1000), and the `revocationRegistryIndex` uniquely identifies the specific credential within the registry. Failing to maintain these parameters correctly may lead to issues during the credential revocation process.

```json
{
...
"type": "credential-issuance",
"credentialDefinitionId": "id",
"claims": [{ "name": "claim-name", "mimeType": "mime-type", "value": "claim-value" }, ...]
"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 revoked and a notification is sent to the DIDComm connection it has been issued to.

In this context, `threadId` is used to identify the details of the credential

```json
{
...
"type": "credential-revocation",
}
```

Expand Down
48 changes: 47 additions & 1 deletion examples/chatbot/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import {
ContextualMenuSelectMessage,
ContextualMenuUpdateMessage,
CredentialIssuanceMessage,
CredentialRevocationMessage,
EMrtdDataRequestMessage,
EMrtdDataSubmitMessage,
IdentityProofRequestMessage,
Expand All @@ -22,6 +23,7 @@ import {
VerifiableCredentialSubmittedProofItem,
MediaMessage,
MrtdSubmitState,
CredentialReceptionMessage,
} from '@2060.io/service-agent-model'
import cors from 'cors'
import { randomUUID } from 'crypto'
Expand Down Expand Up @@ -58,6 +60,8 @@ app.set('json spaces', 2)
const expressHandler = new ExpressEventHandler(app)

let phoneNumberCredentialDefinitionId: string | undefined
let phoneNumberRevocationDefinitionId: string | undefined
let phoneNumberRevocationCount: number = 0

type OngoingCall = {
wsUrl: string
Expand All @@ -83,9 +87,27 @@ const server = app.listen(PORT, async () => {
)

try {
/**
* Note: To test credential revocation locally, use the following configuration:
* const credentialDefinition = (await apiClient.credentialTypes.create({
* id: randomUUID(),
* name: "phoneNumber",
* version: '1.0',
* attributes: ['phoneNumber'],
* supportRevocation: true
* }))
*/
const credentialDefinition = (await apiClient.credentialTypes.import(phoneCredDefData))
phoneNumberCredentialDefinitionId =
phoneNumberCredentialType?.id ?? (await apiClient.credentialTypes.import(phoneCredDefData)).id
phoneNumberCredentialType?.id ?? credentialDefinition.id
logger.info(`phoneNumberCredentialDefinitionId: ${phoneNumberCredentialDefinitionId}`)
phoneNumberRevocationDefinitionId =
(await apiClient.revocationRegistry.get(phoneNumberCredentialDefinitionId))[0] ??
await apiClient.revocationRegistry.create({
credentialDefinitionId: phoneNumberCredentialDefinitionId,
maximumCredentialNumber: 1000
})
logger.info(`phoneNumberRevocationDefinitionId: ${phoneNumberRevocationDefinitionId}`)
} catch (error) {
logger.error(`Could not create or retrieve phone number credential type: ${error}`)
}
Expand Down Expand Up @@ -170,6 +192,8 @@ const handleMenuSelection = async (options: { connectionId: string; item: string
value: '+5712345678',
},
],
revocationRegistryDefinitionId: phoneNumberRevocationDefinitionId,
revocationRegistryIndex: phoneNumberRevocationCount += 1,
})
await apiClient.messages.send(body)
}
Expand Down Expand Up @@ -403,6 +427,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,
Expand Down Expand Up @@ -486,6 +526,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}`,
})
}
})

Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@
"prebuild": "yarn workspace @2060.io/service-agent-model run build",
"build": "yarn workspaces run build",
"start": "yarn workspace @2060.io/service-agent-main run start",
"start:dev": "yarn workspace @2060.io/service-agent-main run start:dev",
"check-types": "yarn check-types:build",
"check-types:build": "yarn workspaces run tsc --noEmit -p tsconfig.build.json",
"format": "prettier \"packages/*/src/**/*.ts\" --write",
Expand Down
8 changes: 6 additions & 2 deletions packages/client/src/ApiClient.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
// src/ApiClient.ts

import { RevocationRegistryService } from './services'
import { CredentialTypeService } from './services/CredentialTypeService'
import { MessageService } from './services/MessageService'
import { ApiVersion } from './types/enums'
Expand All @@ -23,24 +24,27 @@ import { ApiVersion } from './types/enums'
* const apiClient = new ApiClient('http://localhost', ApiVersion.V1)
*
* // Example to query available credentials
* await apiClient.credentialType.getAllCredentialTypes()
* await apiClient.credentialType.getAll()
*
* // Example to send a message
* apiClient.message.sendMessage(message: BaseMessage)
* apiClient.message.send(message: BaseMessage)
*
* The `ApiClient` class provides easy methods for interacting with:
* - `message`: Send and manage messages.
* - `credentialType`: Query and manage credential types.
* - `revocationRegistry`: Query and manage the revocation registry for credential definitions.
*/
export class ApiClient {
public readonly messages: MessageService
public readonly credentialTypes: CredentialTypeService
public readonly revocationRegistry: RevocationRegistryService

constructor(
private baseURL: string,
private version: ApiVersion = ApiVersion.V1,
) {
this.messages = new MessageService(baseURL, version)
this.credentialTypes = new CredentialTypeService(baseURL, version)
this.revocationRegistry = new RevocationRegistryService(baseURL, version)
}
}
4 changes: 2 additions & 2 deletions packages/client/src/services/CredentialTypeService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,13 +40,13 @@ export class CredentialTypeService {
return (await response.json()) as CredentialTypeInfo
}

public async create(credentialType: CredentialTypeInfo): Promise<any> {
public async create(credentialType: CredentialTypeInfo): Promise<CredentialTypeInfo> {
const response = await fetch(`${this.url}`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(credentialType),
})
return response.json()
return (await response.json()) as CredentialTypeInfo
}

public async getAll(): Promise<CredentialTypeInfo[]> {
Expand Down
74 changes: 74 additions & 0 deletions packages/client/src/services/RevocationRegistryService.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,74 @@
// src/services/RevocationRegistryService.ts

import { RevocationRegistryInfo } from '@2060.io/service-agent-model'
import { Logger } from 'tslog'

import { ApiVersion } from '../types/enums'

const logger = new Logger({
name: 'RevocationRegistryService',
type: 'pretty',
prettyLogTemplate: '{{logLevelName}} [{{name}}]: ',
})

/**
* `RevocationRegistryService` class for managing credential types and interacting with
* the available endpoints related to credential types in the Agent Service.
*
* This class provides methods for querying, creating, and managing revocation registry on credential types.
* For a list of available endpoints and functionality, refer to the methods within this class.
*/
export class RevocationRegistryService {
private url: string

constructor(
private baseURL: string,
private version: ApiVersion,
) {
this.url = `${this.baseURL.replace(/\/$/, '')}/${this.version}/credential-types`
}

public async create(options: RevocationRegistryInfo): Promise<string | undefined> {
const response = await fetch(`${this.url}/revocationRegistry`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(options),
})

if (!response.ok) {
logger.error(`Failed to create revocation registry`)
return undefined
}

return await response.text()
}

public async get(credentialDefinitionId: string): Promise<string[]> {
const response = await fetch(
`${this.url}/revocationRegistry?credentialDefinitionId=${encodeURIComponent(credentialDefinitionId)}`,
{
method: 'GET',
headers: { 'Content-Type': 'application/json' },
},
)

if (!response.ok) {
throw new Error(`Failed to fetch revocation definitions: ${response.statusText}`)
}

return (await response.json()) as string[]
}

public async getAll(): Promise<string[]> {
const response = await fetch(`${this.url}/revocationRegistry`, {
method: 'GET',
headers: { 'Content-Type': 'application/json' },
})

if (!response.ok) {
throw new Error(`Failed to fetch revocation registries: ${response.statusText}`)
}

return (await response.json()) as string[]
}
}
1 change: 1 addition & 0 deletions packages/client/src/services/index.ts
Original file line number Diff line number Diff line change
@@ -1,2 +1,3 @@
export * from './CredentialTypeService'
export * from './MessageService'
export * from './RevocationRegistryService'
1 change: 1 addition & 0 deletions packages/main/.gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -5,3 +5,4 @@ build
*.txn
logs.txt
examples/**/afj
tails/
1 change: 1 addition & 0 deletions packages/main/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,7 @@
"@nestjs/platform-express": "^10.0.0",
"@nestjs/swagger": "^7.3.0",
"@types/qrcode": "^1.5.0",
"axios": "^1.7.9",
"body-parser": "^1.20.0",
"bull": "^4.16.2",
"cors": "^2.8.5",
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,23 @@
import { ApiProperty } from '@nestjs/swagger'
import { IsString, IsNotEmpty, IsNumber, IsOptional } from 'class-validator'

export class CreateRevocationRegistryDto {
@ApiProperty({
description: 'credentialDefinitionId',
example:
'did:web:chatbot-demo.dev.2060.io?service=anoncreds&relativeRef=/credDef/8TsGLaSPVKPVMXK8APzBRcXZryxutvQuZnnTcDmbqd9p',
})
@IsString()
@IsNotEmpty()
credentialDefinitionId!: string

@ApiProperty({
description: 'maximumCredentialNumber',
default: 1000,
example: 1000,
})
@IsNumber()
@IsNotEmpty()
@IsOptional()
maximumCredentialNumber: number = 1000
}
Loading

0 comments on commit d8d34df

Please sign in to comment.