Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: support w3c revocation #2024

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions packages/core/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@
"luxon": "^3.3.0",
"make-error": "^1.3.6",
"object-inspect": "^1.10.3",
"pako": "^2.1.0",
"query-string": "^7.0.1",
"reflect-metadata": "^0.1.13",
"rxjs": "^7.8.0",
Expand All @@ -71,6 +72,7 @@
"@types/jsonpath": "^0.2.4",
"@types/luxon": "^3.2.0",
"@types/object-inspect": "^1.8.0",
"@types/pako": "^2.0.3",
"@types/uuid": "^9.0.1",
"@types/varint": "^6.0.0",
"nock": "^13.3.0",
Expand Down
110 changes: 109 additions & 1 deletion packages/core/src/modules/credentials/CredentialsApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,18 @@ import type {
DeleteCredentialOptions,
SendRevocationNotificationOptions,
DeclineCredentialOfferOptions,
RevokeCredentialOption,
BitStringCredential,
JsonLdRevocationStatus,
} from './CredentialsApiOptions'
import type { CredentialProtocol } from './protocol/CredentialProtocol'
import type { CredentialFormatsFromProtocols } from './protocol/CredentialProtocolOptions'
import type { CredentialExchangeRecord } from './repository/CredentialExchangeRecord'
import type { AgentMessage } from '../../agent/AgentMessage'
import type { Query, QueryOptions } from '../../storage/StorageService'

import * as pako from 'pako'

import { AgentContext } from '../../agent'
import { MessageSender } from '../../agent/MessageSender'
import { getOutboundMessageContext } from '../../agent/getOutboundMessageContext'
Expand All @@ -32,10 +37,12 @@ import { CredoError } from '../../error'
import { Logger } from '../../logger'
import { inject, injectable } from '../../plugins'
import { DidCommMessageRepository } from '../../storage/didcomm/DidCommMessageRepository'
import { Buffer } from '../../utils'
import { ConnectionService } from '../connections/services'
import { RoutingService } from '../routing/services/RoutingService'

import { CredentialsModuleConfig } from './CredentialsModuleConfig'
import { BitstringStatusListEntry, JsonLdCredentialFormat } from './formats'
import { CredentialState } from './models/CredentialState'
import { RevocationNotificationService } from './protocol/revocation-notification/services'
import { CredentialRepository } from './repository/CredentialRepository'
Expand All @@ -62,9 +69,12 @@ export interface CredentialsApi<CPs extends CredentialProtocol[]> {
// Issue Credential Methods
acceptCredential(options: AcceptCredentialOptions): Promise<CredentialExchangeRecord>

// Revoke Credential Methods
// Revoke JSON-LD credential Methods
sendRevocationNotification(options: SendRevocationNotificationOptions): Promise<void>

// Revoke Credential Methods
revokeJsonLdCredential(options: RevokeCredentialOption): Promise<{ message: string }>

// out of band
createOffer(options: CreateCredentialOfferOptions<CPs>): Promise<{
message: AgentMessage
Expand Down Expand Up @@ -515,6 +525,87 @@ export class CredentialsApi<CPs extends CredentialProtocol[]> implements Credent
return credentialRecord
}

/**
* Revoke a credential by issuer
* associated with the credential record.
*
* @param credentialRecordId The id of the credential record for which to revoke the credential
* @returns Revoke credential notification message
*
*/
public async revokeJsonLdCredential(options: RevokeCredentialOption): Promise<{ message: string }> {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

there is a lot of dupcliation in this file with the already present methods. Some suggestions:

  • move all the credential status logic into a specific folder in the w3c module
  • add the revocation related methods to the w3c credentails api, not the DIDcomm credentials api
  • the only thing we should add it here is a sendRevocationNotification method (which we can extend to support non-anoncreds revocadion notification messages as well)
  • making a POST to the credential status list url to update the status list seems implementation specific and may not work for all implementations

// Default to '1' (revoked)
const revocationStatus = '1' as JsonLdRevocationStatus

const credentialRecord = await this.getCredentialRecord(options.credentialRecordId)
const credentialStatus = this.validateCredentialStatus(credentialRecord)

const { statusListIndex: credentialIndex, statusListCredential: statusListCredentialURL } = credentialStatus
const bitStringCredential = await this.fetchAndValidateBitStringCredential(statusListCredentialURL)
const decodedBitString = await this.decodeBitSting(bitStringCredential.credential.credentialSubject.encodedList)

if (decodedBitString.charAt(Number(credentialIndex)) === revocationStatus) {
throw new CredoError('The JSON-LD credential is already revoked')
}

// Update the bit string with the revocation status
const updatedBitString = this.updateBitString(decodedBitString, credentialIndex, revocationStatus)
bitStringCredential.credential.credentialSubject.encodedList = await this.encodeBitString(updatedBitString)

await this.postUpdatedBitString(statusListCredentialURL, bitStringCredential)

return { message: 'The JSON-LD credential has been successfully revoked.' }
}

private async getCredentialRecord(
credentialRecordId: string
): Promise<GetCredentialFormatDataReturn<JsonLdCredentialFormat[]>> {
return this.getFormatData(credentialRecordId)
}

private validateCredentialStatus(
credentialRecord: GetCredentialFormatDataReturn<JsonLdCredentialFormat[]>
): BitstringStatusListEntry {
const credentialStatus = credentialRecord.offer?.jsonld?.credential?.credentialStatus

if (Array.isArray(credentialStatus)) {
throw new CredoError('This credential status as an array for JSON-LD credentials is currently not supported')
}

if (!credentialStatus) {
throw new CredoError('This JSON-LD credential is non-revocable')
}

return credentialStatus
}

private async fetchAndValidateBitStringCredential(statusListCredentialURL: string): Promise<BitStringCredential> {
const response = await fetch(statusListCredentialURL)
if (!response.ok) {
throw new CredoError(`Failed to fetch credential: ${response.statusText}`)
}
return response.json() as Promise<BitStringCredential>
}

private updateBitString(decodedBitString: string, credentialIndex: string, revocationStatus: string): string {
return [
decodedBitString.slice(0, Number(credentialIndex)),
revocationStatus,
decodedBitString.slice(Number(credentialIndex) + 1),
].join('')
}

private async postUpdatedBitString(
statusListCredentialURL: string,
bitStringCredential: BitStringCredential
): Promise<void> {
await fetch(statusListCredentialURL, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ credentialsData: bitStringCredential }),
})
}

/**
* Send a revocation notification for a credential exchange record. Currently Revocation Notification V2 protocol is supported
*
Expand Down Expand Up @@ -705,4 +796,21 @@ export class CredentialsApi<CPs extends CredentialProtocol[]> implements Credent

return this.getProtocol(credentialExchangeRecord.protocolVersion)
}

private async encodeBitString(bitString: string): Promise<string> {
// Convert the bitString to a Uint8Array
const buffer = new TextEncoder().encode(bitString)
const compressedBuffer = pako.gzip(buffer)
// Convert the compressed buffer to a base64 string
return Buffer.from(compressedBuffer).toString('base64')
}

private async decodeBitSting(bitString: string): Promise<string> {
// Decode base64 string to Uint8Array
const compressedBuffer = Uint8Array.from(atob(bitString), (c) => c.charCodeAt(0))

// Decompress using pako
const decompressedBuffer = pako.ungzip(compressedBuffer, { to: 'string' })
return decompressedBuffer
}
}
27 changes: 27 additions & 0 deletions packages/core/src/modules/credentials/CredentialsApiOptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ interface BaseOptions {
goal?: string
}

export type JsonLdRevocationStatus = '0' | '1'

/**
* Interface for CredentialsApi.proposeCredential. Will send a proposal.
*/
Expand Down Expand Up @@ -171,3 +173,28 @@ export interface DeclineCredentialOfferOptions {
*/
problemReportDescription?: string
}

/**
* Interface for CredentialsApi.revokeCredential. revoke a jsonld credential by Issuer.
*/
export interface RevokeCredentialOption {
credentialRecordId: string
}

export interface CredentialSubject {
id: string
type: string
encodedList: string
statusPurpose: string
}

export interface Credential {
credentialSubject: CredentialSubject
}

/**
* Interface for bit string credential. Representing the bit string credential status.
*/
export interface BitStringCredential {
credential: Credential
}
Original file line number Diff line number Diff line change
Expand Up @@ -11,9 +11,18 @@ export interface JsonCredential {
issuanceDate: string
expirationDate?: string
credentialSubject: SingleOrArray<JsonObject>
credentialStatus?: SingleOrArray<BitstringStatusListEntry>
[key: string]: unknown
}

export interface BitstringStatusListEntry {
id: string
type: string
statusPurpose: string
statusListIndex: string
statusListCredential: string
}

/**
* Format for creating a jsonld proposal, offer or request.
*/
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,71 @@
import type { AgentContext } from '../../../agent/context'
import type { W3cJsonCredential } from '../models/credential/W3cJsonCredential'
import type { BitStringStatusListCredential } from '../models/credential/W3cJsonCredentialStatus'

import * as pako from 'pako'

import { CredoError } from '../../../error'
import { validateStatus } from '../models/credential/W3cCredentialStatus'

// Function to fetch and parse the bit string status list credential
const fetchBitStringStatusListCredential = async (
agentContext: AgentContext,
url: string
): Promise<BitStringStatusListCredential> => {
const response = await agentContext.config.agentDependencies.fetch(url, { method: 'GET' })

if (!response.ok) {
throw new CredoError(`Failed to fetch bit string status list. HTTP Status: ${response.status}`)
}

try {
return (await response.json()) as BitStringStatusListCredential
} catch (error) {
throw new CredoError('Failed to parse the bit string status list credential')
}
}

export const verifyBitStringCredentialStatus = async (credential: W3cJsonCredential, agentContext: AgentContext) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This doesn't validate the signature of the status list credential yet

const { credentialStatus } = credential

if (Array.isArray(credentialStatus)) {
throw new CredoError('Verifying credential status as an array for JSON-LD credentials is currently not supported')
}

if (!credentialStatus || credentialStatus.statusListIndex === undefined) {
throw new CredoError('Invalid credential status format')
}

// Validate credentialStatus using the class-based approach
const isValid = await validateStatus(credentialStatus, agentContext)

if (!isValid) {
throw new CredoError('Invalid credential status type. Expected BitstringStatusList')
}

// Fetch the bit string status list credential
const bitStringStatusListCredential = await fetchBitStringStatusListCredential(
agentContext,
credentialStatus.statusListCredential
)

// Decode the encoded bit string
const encodedBitString = bitStringStatusListCredential.credential.credentialSubject.encodedList
const compressedBuffer = Uint8Array.from(atob(encodedBitString), (char) => char.charCodeAt(0))

// Decompress the bit string using pako
const decodedBitString = pako.ungzip(compressedBuffer, { to: 'string' })
const statusListIndex = Number(credentialStatus.statusListIndex)

// Ensure the statusListIndex is within bounds
if (statusListIndex < 0 || statusListIndex >= decodedBitString.length) {
throw new CredoError('Status list index is out of bounds')
}

// Check if the credential is revoked
if (decodedBitString[statusListIndex] === '1') {
throw new CredoError(`Credential at index ${credentialStatus.statusListIndex} is revoked.`)
}

return true
}
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import { W3cCredentialsModuleConfig } from '../W3cCredentialsModuleConfig'
import { w3cDate } from '../util'

import { SignatureSuiteRegistry } from './SignatureSuiteRegistry'
import { verifyBitStringCredentialStatus } from './VerifyBitStringCredentialStatus'
import { deriveProof } from './deriveProof'
import { assertOnlyW3cJsonLdVerifiableCredentials } from './jsonldUtil'
import jsonld from './libraries/jsonld'
Expand Down Expand Up @@ -109,10 +110,9 @@ export class W3cJsonLdCredentialService {
credential: JsonTransformer.toJSON(options.credential),
suite: suites,
documentLoader: this.w3cCredentialsModuleConfig.documentLoader(agentContext),
checkStatus: ({ credential }: { credential: W3cJsonCredential }) => {
// Only throw error if credentialStatus is present
checkStatus: async ({ credential }: { credential: W3cJsonCredential }) => {
if (verifyCredentialStatus && 'credentialStatus' in credential) {
throw new CredoError('Verifying credential status for JSON-LD credentials is currently not supported')
await verifyBitStringCredentialStatus(credential, agentContext)
}
return {
verified: true,
Expand Down Expand Up @@ -265,6 +265,14 @@ export class W3cJsonLdCredentialService {
challenge: options.challenge,
domain: options.domain,
documentLoader: this.w3cCredentialsModuleConfig.documentLoader(agentContext),
checkStatus: async ({ credential }: { credential: W3cJsonCredential }) => {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you extract this into a method and reuse that for both checkStatus methods?

Copy link
Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@TimoGlastra I have developed a common function for this purpose. However, in the checkStatus method, there are specific conditions related to credential status that are dependent on the payload received from the verifyCredential and verifyPresentation functions. Therefore, the checkStatus method invokes the common function, as it contains the identical logic.

if ('credentialStatus' in credential) {
await verifyBitStringCredentialStatus(credential, agentContext)
}
Comment on lines +269 to +271
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

verifyPresentation also has options.verifyCredentialStatus, can you add that check here as well?

return {
verified: true,
}
},
}

// this is a hack because vcjs throws if purpose is passed as undefined or null
Expand Down
Loading