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 #2072

Draft
wants to merge 4 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 }> {
// 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) => {
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 @@ -259,12 +259,21 @@ export class W3cJsonLdCredentialService {
)
const allSuites = presentationSuites.concat(...credentialSuites)

const verifyCredentialStatus = options.verifyCredentialStatus ?? true
const verifyOptions: Record<string, unknown> = {
presentation: JsonTransformer.toJSON(options.presentation),
suite: allSuites,
challenge: options.challenge,
domain: options.domain,
documentLoader: this.w3cCredentialsModuleConfig.documentLoader(agentContext),
checkStatus: async ({ credential }: { credential: W3cJsonCredential }) => {
if (verifyCredentialStatus && 'credentialStatus' in credential) {
await verifyBitStringCredentialStatus(credential, agentContext)
}
return {
verified: true,
}
},
}

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