diff --git a/config.json b/config.json new file mode 100644 index 00000000..09b06448 --- /dev/null +++ b/config.json @@ -0,0 +1,4 @@ +{ + "port": 4001, + "schemaFileServerURL": "https://schema.credebl.id/schemas/" +} \ No newline at end of file diff --git a/package.json b/package.json index a5edce5d..67a2b946 100644 --- a/package.json +++ b/package.json @@ -48,6 +48,7 @@ "@credo-ts/push-notifications": "^0.7.0", "@credo-ts/question-answer": "0.5.3", "@credo-ts/tenants": "0.5.3", + "@digitalbazaar/vc-status-list": "^7.1.0", "@hyperledger/anoncreds-nodejs": "0.2.2", "@hyperledger/aries-askar-nodejs": "0.2.1", "@hyperledger/indy-vdr-nodejs": "0.2.2", @@ -82,6 +83,7 @@ "@types/jsonwebtoken": "^9.0.5", "@types/multer": "^1.4.7", "@types/node": "^18.18.8", + "@types/pako": "^2.0.3", "@types/ref-array-di": "^1.2.8", "@types/ref-struct-di": "^1.1.9", "@types/supertest": "^2.0.12", diff --git a/patches/@credo-ts+core+0.5.3+004+revocable-credential.patch b/patches/@credo-ts+core+0.5.3+004+revocable-credential.patch new file mode 100644 index 00000000..6c878feb --- /dev/null +++ b/patches/@credo-ts+core+0.5.3+004+revocable-credential.patch @@ -0,0 +1,174 @@ +diff --git a/node_modules/@credo-ts/core/build/modules/credentials/formats/jsonld/JsonLdCredentialFormat.d.ts b/node_modules/@credo-ts/core/build/modules/credentials/formats/jsonld/JsonLdCredentialFormat.d.ts +index d12468b..78253b5 100644 +--- a/node_modules/@credo-ts/core/build/modules/credentials/formats/jsonld/JsonLdCredentialFormat.d.ts ++++ b/node_modules/@credo-ts/core/build/modules/credentials/formats/jsonld/JsonLdCredentialFormat.d.ts +@@ -11,7 +11,81 @@ export interface JsonCredential { + expirationDate?: string; + credentialSubject: SingleOrArray; + [key: string]: unknown; ++ credentialStatus?: SingleOrArray + } ++ ++type CredentialStatusType = 'BitstringStatusListEntry' ++// The purpose can be anything apart from this as well ++export enum CredentialStatusPurpose { ++ 'revocation' = 'revocation', ++ 'suspension' = 'suspension', ++ 'message' = 'message', ++} ++ ++export interface StatusMessage { ++ // a string representing the hexadecimal value of the status prefixed with 0x ++ status: string ++ // a string used by software developers to assist with debugging which SHOULD NOT be displayed to end users ++ message?: string ++ // We can have some key value pairs as well ++ [key: string]: unknown ++} ++/** ++ "credentialStatus": { ++ "id": "https://example.com/credentials/status/8#492847", ++ "type": "BitstringStatusListEntry", ++ "statusPurpose": "message", ++ "statusListIndex": "492847", ++ "statusSize": 2, ++ "statusListCredential": "https://example.com/credentials/status/8", ++ "statusMessage": [ ++ {"status":"0x0", "message":"pending_review"}, ++ {"status":"0x1", "message":"accepted"}, ++ {"status":"0x2", "message":"rejected"}, ++ ... ++ ], ++ "statusReference": "https://example.org/status-dictionary/" ++} ++*/ ++ ++/** ++* "credentialStatus": [{ ++ "id": "https://example.com/credentials/status/3#94567", ++ "type": "BitstringStatusListEntry", ++ "statusPurpose": "revocation", ++ "statusListIndex": "94567", ++ "statusListCredential": "https://example.com/credentials/status/3" ++}, { ++ "id": "https://example.com/credentials/status/4#23452", ++ "type": "BitstringStatusListEntry", ++ "statusPurpose": "suspension", ++ "statusListIndex": "23452", ++ "statusListCredential": "https://example.com/credentials/status/4" ++}] ++*/ ++ ++export interface CredentialStatus { ++ id: string ++ // Since currenlty we are only trying to support 'BitStringStatusListEntry' ++ type: CredentialStatusType ++ statusPurpose: CredentialStatusPurpose ++ // Unique identifier for the specific credential ++ statusListIndex: string ++ // Must be url referencing to a VC of type 'BitstringStatusListCredential' ++ statusListCredential: string ++ // The statusSize indicates the size of the status entry in bits ++ statusSize?: number ++ // Must be preset if statusPurpose is message ++ /** ++ * the length of which MUST equal the number of possible status messages indicated by statusSize ++ * (e.g., statusMessage array MUST have 2 elements if statusSize has 1 bit, ++ * 4 elements if statusSize has 2 bits, 8 elements if statusSize has 3 bits, etc.). ++ */ ++ statusMessage?: StatusMessage[] ++ // An implementer MAY include the statusReference property. If present, its value MUST be a URL or an array of URLs [URL] which dereference to material related to the status ++ statusReference?: SingleOrArray ++} ++ + /** + * Format for creating a jsonld proposal, offer or request. + */ +diff --git a/node_modules/@credo-ts/core/build/modules/vc/data-integrity/W3cJsonLdCredentialService.js b/node_modules/@credo-ts/core/build/modules/vc/data-integrity/W3cJsonLdCredentialService.js +index 3fa8bf2..f28be5c 100644 +--- a/node_modules/@credo-ts/core/build/modules/vc/data-integrity/W3cJsonLdCredentialService.js ++++ b/node_modules/@credo-ts/core/build/modules/vc/data-integrity/W3cJsonLdCredentialService.js +@@ -39,6 +39,7 @@ const jsonld_1 = __importDefault(require("./libraries/jsonld")); + const vc_1 = __importDefault(require("./libraries/vc")); + const models_1 = require("./models"); + const W3cJsonLdVerifiablePresentation_1 = require("./models/W3cJsonLdVerifiablePresentation"); ++const pako = require('pako'); + /** + * Supports signing and verification of credentials according to the [Verifiable Credential Data Model](https://www.w3.org/TR/vc-data-model) + * using [Data Integrity Proof](https://www.w3.org/TR/vc-data-model/#data-integrity-proofs). +@@ -98,10 +99,35 @@ let W3cJsonLdCredentialService = class W3cJsonLdCredentialService { + credential: utils_1.JsonTransformer.toJSON(options.credential), + suite: suites, + documentLoader: this.w3cCredentialsModuleConfig.documentLoader(agentContext), +- checkStatus: ({ credential }) => { ++ checkStatus: async ({ credential }) => { + // Only throw error if credentialStatus is present + if (verifyCredentialStatus && 'credentialStatus' in credential) { +- throw new error_1.CredoError('Verifying credential status for JSON-LD credentials is currently not supported'); ++ // throw new error_1.CredoError('Verifying credential status for JSON-LD credentials is currently not supported'); ++ // TODO: add logic to verify credentialStatus ++ // throw new CredoError('Verifying credential status for JSON-LD credentials is currently not supported') ++ const credentialStatusURL = credential.credentialStatus.statusListCredential; ++ const bitStringStatusListCredential = await agentContext.config.agentDependencies.fetch(credentialStatusURL, { ++ method: 'GET', ++ }); ++ ++ if (!bitStringStatusListCredential.ok) { ++ throw new error_1.CredoError(`HTTP error! Status: ${bitStringStatusListCredential.status}`); ++ } ++ ++ const bitStringCredential = await bitStringStatusListCredential.json(); ++ const encodedBitString = bitStringCredential.credential.credentialSubject.encodedList ++ const compressedBuffer = Uint8Array.from(atob(encodedBitString), (c) => c.charCodeAt(0)) ++ ++ // Decompress using pako ++ const decodedBitString = pako.ungzip(compressedBuffer, { to: 'string' }) ++ ++ if (credential.credentialStatus.statusListIndex < 0 || credential.credentialStatus.statusListIndex >= decodedBitString.length) { ++ throw new error_1.CredoError('Index out of bounds'); ++ } ++ ++ if(decodedBitString[credential.credentialStatus.statusListIndex] === '1'){ ++ throw new error_1.CredoError(`Credential at index ${credential.credentialStatus.statusListIndex} is revoked.`); ++ } + } + return { + verified: true, +@@ -219,6 +245,37 @@ let W3cJsonLdCredentialService = class W3cJsonLdCredentialService { + challenge: options.challenge, + domain: options.domain, + documentLoader: this.w3cCredentialsModuleConfig.documentLoader(agentContext), ++ checkStatus: async ({ credential }) => { ++ if ('credentialStatus' in credential) { ++ ++ const credentialStatusURL = credential.credentialStatus.statusListCredential; ++ const bitStringStatusListCredential = await agentContext.config.agentDependencies.fetch(credentialStatusURL, { ++ method: 'GET', ++ }); ++ ++ if (!bitStringStatusListCredential.ok) { ++ throw new error_1.CredoError(`HTTP error! Status: ${bitStringStatusListCredential.status}`); ++ } ++ ++ const bitStringCredential = await bitStringStatusListCredential.json(); ++ const encodedBitString = bitStringCredential.credential.credentialSubject.encodedList ++ const compressedBuffer = Uint8Array.from(atob(encodedBitString), (c) => c.charCodeAt(0)) ++ ++ // Decompress using pako ++ const decodedBitString = pako.ungzip(compressedBuffer, { to: 'string' }) ++ ++ if (credential.credentialStatus.statusListIndex < 0 || credential.credentialStatus.statusListIndex >= decodedBitString.length) { ++ throw new error_1.CredoError('Index out of bounds'); ++ } ++ ++ if(decodedBitString[credential.credentialStatus.statusListIndex] === '1'){ ++ throw new error_1.CredoError(`Credential at index ${credential.credentialStatus.statusListIndex} is revoked.`); ++ } ++ } ++ return { ++ verified: true, ++ } ++ }, + }; + // this is a hack because vcjs throws if purpose is passed as undefined or null + if (options.purpose) { diff --git a/patches/@credo-ts+core+0.5.3+004+added-prettyVc-in-JsonCredential-interface.patch b/patches/@credo-ts+core+0.5.3+005+added-pretty-vc.patch similarity index 73% rename from patches/@credo-ts+core+0.5.3+004+added-prettyVc-in-JsonCredential-interface.patch rename to patches/@credo-ts+core+0.5.3+005+added-pretty-vc.patch index 32598651..0e92cdee 100644 --- a/patches/@credo-ts+core+0.5.3+004+added-prettyVc-in-JsonCredential-interface.patch +++ b/patches/@credo-ts+core+0.5.3+005+added-pretty-vc.patch @@ -1,13 +1,13 @@ diff --git a/node_modules/@credo-ts/core/build/modules/credentials/formats/jsonld/JsonLdCredentialFormat.d.ts b/node_modules/@credo-ts/core/build/modules/credentials/formats/jsonld/JsonLdCredentialFormat.d.ts -index d12468b..ae70f36 100644 +index 78253b5..0b3dd52 100644 --- a/node_modules/@credo-ts/core/build/modules/credentials/formats/jsonld/JsonLdCredentialFormat.d.ts +++ b/node_modules/@credo-ts/core/build/modules/credentials/formats/jsonld/JsonLdCredentialFormat.d.ts -@@ -10,6 +10,8 @@ export interface JsonCredential { - issuanceDate: string; - expirationDate?: string; +@@ -12,6 +12,8 @@ export interface JsonCredential { credentialSubject: SingleOrArray; + [key: string]: unknown; + credentialStatus?: SingleOrArray + //TODO change type + prettyVc?: any; - [key: string]: unknown; } - /** + + type CredentialStatusType = 'BitstringStatusListEntry' diff --git a/patches/@credo-ts+core+0.5.3+006+revocation-notification.patch b/patches/@credo-ts+core+0.5.3+006+revocation-notification.patch new file mode 100644 index 00000000..7539d3a4 --- /dev/null +++ b/patches/@credo-ts+core+0.5.3+006+revocation-notification.patch @@ -0,0 +1,127 @@ +diff --git a/node_modules/@credo-ts/core/build/modules/credentials/formats/jsonld/JsonLdCredentialFormatService.js b/node_modules/@credo-ts/core/build/modules/credentials/formats/jsonld/JsonLdCredentialFormatService.js +index 83f99b7..152cf2a 100644 +--- a/node_modules/@credo-ts/core/build/modules/credentials/formats/jsonld/JsonLdCredentialFormatService.js ++++ b/node_modules/@credo-ts/core/build/modules/credentials/formats/jsonld/JsonLdCredentialFormatService.js +@@ -75,7 +75,7 @@ class JsonLdCredentialFormatService { + * @returns object containing associated attachment, formats and offersAttach elements + * + */ +- async createOffer(agentContext, { credentialFormats, attachmentId }) { ++ async createOffer(agentContext, { credentialFormats, attachmentId, credentialRecord }) { + // if the offer has an attachment Id use that, otherwise the generated id of the formats object + const format = new CredentialFormatSpec_1.CredentialFormatSpec({ + attachmentId, +@@ -88,6 +88,12 @@ class JsonLdCredentialFormatService { + // validate + JsonTransformer_1.JsonTransformer.fromJSON(jsonLdFormat.credential, JsonLdCredentialDetail_1.JsonLdCredentialDetail); + const attachment = this.getFormatData(jsonLdFormat, format.attachmentId); ++ if (jsonLdFormat.credential.credentialStatus) { ++ credentialRecord.setTags({ ++ statusListCredential: jsonLdFormat.credential.credentialStatus.statusListCredential, ++ statusListIndex: jsonLdFormat.credential.credentialStatus.statusListIndex, ++ }) ++ } + return { format, attachment }; + } + async processOffer(agentContext, { attachment }) { +@@ -136,7 +142,7 @@ class JsonLdCredentialFormatService { + // validate + JsonTransformer_1.JsonTransformer.fromJSON(requestJson, JsonLdCredentialDetail_1.JsonLdCredentialDetail); + } +- async acceptRequest(agentContext, { credentialFormats, attachmentId, requestAttachment }) { ++ async acceptRequest(agentContext, { credentialFormats, attachmentId, requestAttachment, credentialRecord }) { + var _a, _b; + const w3cJsonLdCredentialService = agentContext.dependencyManager.resolve(W3cJsonLdCredentialService_1.W3cJsonLdCredentialService); + // sign credential here. credential to be signed is received as the request attachment +@@ -164,6 +170,13 @@ class JsonLdCredentialFormatService { + proofType: credentialRequest.options.proofType, + verificationMethod: verificationMethod, + }); ++ // If the credential is revocable, store the revocation identifiers in the credential record ++ if (credential.credentialStatus) { ++ credentialRecord.setTags({ ++ statusListCredential: credential.credentialStatus.statusListCredential, ++ statusListIndex: credential.credentialStatus.statusListIndex, ++ }) ++ } + const attachment = this.getFormatData(JsonTransformer_1.JsonTransformer.toJSON(verifiableCredential), format.attachmentId); + return { format, attachment }; + } +@@ -216,6 +229,12 @@ class JsonLdCredentialFormatService { + const verifiableCredential = await w3cCredentialService.storeCredential(agentContext, { + credential, + }); ++ if (credential.credentialStatus) { ++ credentialRecord.setTags({ ++ statusListCredential: credential.credentialStatus.statusListCredential, ++ statusListIndex: credential.credentialStatus.statusListIndex, ++ }) ++ } + credentialRecord.credentials.push({ + credentialRecordType: this.credentialRecordType, + credentialRecordId: verifiableCredential.id, +diff --git a/node_modules/@credo-ts/core/build/modules/credentials/protocol/revocation-notification/services/RevocationNotificationService.js b/node_modules/@credo-ts/core/build/modules/credentials/protocol/revocation-notification/services/RevocationNotificationService.js +index 66ed4e5..083dfce 100644 +--- a/node_modules/@credo-ts/core/build/modules/credentials/protocol/revocation-notification/services/RevocationNotificationService.js ++++ b/node_modules/@credo-ts/core/build/modules/credentials/protocol/revocation-notification/services/RevocationNotificationService.js +@@ -32,10 +32,17 @@ let RevocationNotificationService = class RevocationNotificationService { + this.registerMessageHandlers(messageHandlerRegistry); + } + async processRevocationNotification(agentContext, anonCredsRevocationRegistryId, anonCredsCredentialRevocationId, connection, comment) { ++ let credentialRecord; ++ + // TODO: can we extract support for this revocation notification handler to the anoncreds module? + const query = { anonCredsRevocationRegistryId, anonCredsCredentialRevocationId, connectionId: connection.id }; ++ + this.logger.trace(`Getting record by query for revocation notification:`, query); +- const credentialRecord = await this.credentialRepository.getSingleByQuery(agentContext, query); ++ if(new URL(anonCredsRevocationRegistryId)){ ++ credentialRecord = await this.credentialRepository.getSingleByQuery(agentContext, { connectionId: connection.id, statusListCredential: anonCredsRevocationRegistryId, statusListIndex: anonCredsCredentialRevocationId }); ++ } else { ++ credentialRecord = await this.credentialRepository.getSingleByQuery(agentContext, query); ++ } + credentialRecord.revocationNotification = new RevocationNotification_1.RevocationNotification(comment); + await this.credentialRepository.update(agentContext, credentialRecord); + this.logger.trace('Emitting RevocationNotificationReceivedEvent'); +@@ -96,11 +103,16 @@ let RevocationNotificationService = class RevocationNotificationService { + var _a; + this.logger.info('Processing revocation notification v2', { message: messageContext.message }); + const credentialId = messageContext.message.credentialId; +- if (![revocationIdentifier_1.v2IndyRevocationFormat, revocationIdentifier_1.v2AnonCredsRevocationFormat].includes(messageContext.message.revocationFormat)) { ++ if (![revocationIdentifier_1.v2IndyRevocationFormat, revocationIdentifier_1.v2AnonCredsRevocationFormat, revocationIdentifier_1.v2JsonLdRevocationFormat].includes(messageContext.message.revocationFormat)) { + throw new CredoError_1.CredoError(`Unknown revocation format: ${messageContext.message.revocationFormat}. Supported formats are indy-anoncreds and anoncreds`); + } + try { +- const credentialIdGroups = (_a = credentialId.match(revocationIdentifier_1.v2IndyRevocationIdentifierRegex)) !== null && _a !== void 0 ? _a : credentialId.match(revocationIdentifier_1.v2AnonCredsRevocationIdentifierRegex); ++ const credentialIdGroups = ( ++ (_a = credentialId.match(revocationIdentifier_1.v2IndyRevocationIdentifierRegex)) !== null && _a !== void 0 ++ ? _a ++ : credentialId.match(revocationIdentifier_1.v2AnonCredsRevocationIdentifierRegex) ?? credentialId.match(revocationIdentifier_1.v2JsonLdRevocationRegex) ++ ); ++ + if (!credentialIdGroups) { + throw new CredoError_1.CredoError(`Incorrect revocation notification credentialId format: \n${credentialId}\ndoes not match\n"::"`); + } +diff --git a/node_modules/@credo-ts/core/build/modules/credentials/protocol/revocation-notification/util/revocationIdentifier.d.ts b/node_modules/@credo-ts/core/build/modules/credentials/protocol/revocation-notification/util/revocationIdentifier.d.ts +index 0cf5132..7b9497a 100644 +--- a/node_modules/@credo-ts/core/build/modules/credentials/protocol/revocation-notification/util/revocationIdentifier.d.ts ++++ b/node_modules/@credo-ts/core/build/modules/credentials/protocol/revocation-notification/util/revocationIdentifier.d.ts +@@ -3,3 +3,5 @@ export declare const v2IndyRevocationIdentifierRegex: RegExp; + export declare const v2IndyRevocationFormat = "indy-anoncreds"; + export declare const v2AnonCredsRevocationIdentifierRegex: RegExp; + export declare const v2AnonCredsRevocationFormat = "anoncreds"; ++export declare const v2JsonLdRevocationRegex: RegExp; ++export declare const v2JsonLdRevocationFormat = 'jsonld' +diff --git a/node_modules/@credo-ts/core/build/modules/credentials/protocol/revocation-notification/util/revocationIdentifier.js b/node_modules/@credo-ts/core/build/modules/credentials/protocol/revocation-notification/util/revocationIdentifier.js +index 55a0cec..95fbe8a 100644 +--- a/node_modules/@credo-ts/core/build/modules/credentials/protocol/revocation-notification/util/revocationIdentifier.js ++++ b/node_modules/@credo-ts/core/build/modules/credentials/protocol/revocation-notification/util/revocationIdentifier.js +@@ -10,4 +10,7 @@ exports.v2IndyRevocationFormat = 'indy-anoncreds'; + // CredentialID = :: + exports.v2AnonCredsRevocationIdentifierRegex = /([a-zA-Z0-9+\-.]+:.+)::(\d+)$/; + exports.v2AnonCredsRevocationFormat = 'anoncreds'; ++ ++exports.v2JsonLdRevocationRegex = /^(https?:\/\/)?([\w.-]+)(:\d+)?(\/[^\s]*)?$/ ++exports.v2JsonLdRevocationFormat = 'jsonld'; + //# sourceMappingURL=revocationIdentifier.js.map +\ No newline at end of file diff --git a/src/controllers/credentials/CredentialController.ts b/src/controllers/credentials/CredentialController.ts index c1eccc05..f548bcc7 100644 --- a/src/controllers/credentials/CredentialController.ts +++ b/src/controllers/credentials/CredentialController.ts @@ -1,7 +1,9 @@ import type { RestAgentModules } from '../../cliAgent' +import type { CredentialStatusList, OobOffer } from '../types' import type { CredentialExchangeRecordProps, CredentialProtocolVersionType, + OutOfBandRecord, PeerDidNumAlgo2CreateOptions, Routing, } from '@credo-ts/core' @@ -16,7 +18,10 @@ import { } from '@credo-ts/core' import { injectable } from 'tsyringe' +import { BitStringCredentialStatusPurpose } from '../../enums/enum' import ErrorHandlingService from '../../errorHandlingService' +import { BadRequestError } from '../../errors/errors' +import utils from '../../utils/credentialStatusList' import { CredentialExchangeRecordExample, RecordId } from '../examples' import { OutOfBandController } from '../outofband/OutOfBandController' import { @@ -167,8 +172,41 @@ export class CredentialController extends Controller { @Post('/create-offer') public async createOffer(@Body() createOfferOptions: CreateOfferOptions) { try { - const offer = await this.agent.credentials.offerCredential(createOfferOptions) - return offer + const { credentialFormats, isRevocable, credentialSubjectUrl, statusPurpose } = createOfferOptions + + if (!credentialFormats.jsonld) { + return await this.agent.credentials.offerCredential(createOfferOptions) + } + + if (isRevocable) { + if (!credentialSubjectUrl) { + throw new BadRequestError('Please provide valid credentialSubjectUrl') + } + + const bitStringCredentialStatusPurpose = statusPurpose ?? BitStringCredentialStatusPurpose.REVOCATION + const credentialStatusData = { + credentialSubjectUrl, + statusPurpose: bitStringCredentialStatusPurpose, + } as unknown as CredentialStatusList + const getIndex = await this.agent.genericRecords.findAllByQuery({ + statusListCredentialURL: credentialSubjectUrl, + }) + + const credentialStatus = await utils.getCredentialStatus(credentialStatusData, getIndex) + credentialFormats.jsonld.credential.credentialStatus = credentialStatus + + const offer = await this.agent.credentials.offerCredential(createOfferOptions) + + await this.agent.genericRecords.save({ + content: { index: credentialStatus.statusListIndex }, + tags: { statusListCredentialURL: credentialStatus.statusListCredential }, + id: offer.id, + }) + + return offer + } + + return await this.agent.credentials.offerCredential(createOfferOptions) } catch (error) { throw ErrorHandlingService.handle(error) } @@ -177,6 +215,7 @@ export class CredentialController extends Controller { @Post('/create-offer-oob') public async createOfferOob(@Body() outOfBandOption: CreateOfferOobOptions) { try { + const { isRevocable, credentialSubjectUrl, statusPurpose, credentialFormats } = outOfBandOption let invitationDid: string | undefined let routing: Routing const linkSecretIds = await this.agent.modules.anoncreds.getLinkSecretIds() @@ -206,6 +245,49 @@ export class CredentialController extends Controller { invitationDid = did.didState.did } + if (isRevocable) { + if (!credentialFormats.jsonld) { + throw new BadRequestError('This credential is not formatted as JSON-LD') + } + + if (!credentialSubjectUrl) { + throw new BadRequestError('Please provide valid credentialSubjectUrl') + } + + const bitStringCredentialStatusPurpose = statusPurpose ?? BitStringCredentialStatusPurpose.REVOCATION + const credentialStatusData = { + credentialSubjectUrl, + statusPurpose: bitStringCredentialStatusPurpose, + } as unknown as CredentialStatusList + const getIndex = await this.agent.genericRecords.findAllByQuery({ + statusListCredentialURL: credentialSubjectUrl, + }) + + const credentialStatus = await utils.getCredentialStatus(credentialStatusData, getIndex) + credentialFormats.jsonld.credential.credentialStatus = credentialStatus + + const offerOob = await this._createOffer(outOfBandOption) + const outOfBandRecord = await this._createInvitation(outOfBandOption, offerOob, invitationDid) + + await this.agent.genericRecords.save({ + content: { index: credentialStatus.statusListIndex }, + tags: { statusListCredentialURL: credentialStatus.statusListCredential }, + id: offerOob.credentialRecord.id, + }) + + return this._buildOobOfferResponse(outOfBandRecord, outOfBandOption, invitationDid) + } + + const offerOob = await this._createOffer(outOfBandOption) + const outOfBandRecord = await this._createInvitation(outOfBandOption, offerOob, invitationDid) + return this._buildOobOfferResponse(outOfBandRecord, outOfBandOption, invitationDid) + } catch (error) { + throw ErrorHandlingService.handle(error) + } + } + + private async _createOffer(outOfBandOption: CreateOfferOobOptions): Promise { + try { const offerOob = await this.agent.credentials.createOffer({ protocolVersion: outOfBandOption.protocolVersion as CredentialProtocolVersionType<[]>, credentialFormats: outOfBandOption.credentialFormats, @@ -213,6 +295,18 @@ export class CredentialController extends Controller { comment: outOfBandOption.comment, }) + return offerOob + } catch (error) { + throw ErrorHandlingService.handle(error) + } + } + + private async _createInvitation( + outOfBandOption: CreateOfferOobOptions, + offerOob: OobOffer, + invitationDid?: string + ): Promise { + try { const credentialMessage = offerOob.message const outOfBandRecord = await this.agent.oob.createInvitation({ label: outOfBandOption.label, @@ -221,21 +315,29 @@ export class CredentialController extends Controller { imageUrl: outOfBandOption?.imageUrl, invitationDid, }) - return { - invitationUrl: outOfBandRecord.outOfBandInvitation.toUrl({ - domain: this.agent.config.endpoints[0], - }), - invitation: outOfBandRecord.outOfBandInvitation.toJSON({ - useDidSovPrefixWhereAllowed: this.agent.config.useDidSovPrefixWhereAllowed, - }), - outOfBandRecord: outOfBandRecord.toJSON(), - invitationDid: outOfBandOption?.invitationDid ? '' : invitationDid, - } + return outOfBandRecord } catch (error) { throw ErrorHandlingService.handle(error) } } + private async _buildOobOfferResponse( + outOfBandRecord: OutOfBandRecord, + outOfBandOption: CreateOfferOobOptions, + invitationDid?: string + ) { + return { + invitationUrl: outOfBandRecord.outOfBandInvitation.toUrl({ + domain: this.agent.config.endpoints[0], + }), + invitation: outOfBandRecord.outOfBandInvitation.toJSON({ + useDidSovPrefixWhereAllowed: this.agent.config.useDidSovPrefixWhereAllowed, + }), + outOfBandRecord: outOfBandRecord.toJSON(), + invitationDid: outOfBandOption?.invitationDid ? '' : invitationDid, + } + } + /** * Accept a credential offer as holder by sending an accept offer message * to the connection associated with the credential exchange record. diff --git a/src/controllers/multi-tenancy/MultiTenancyController.ts b/src/controllers/multi-tenancy/MultiTenancyController.ts index 3bfc0f76..31ec5d20 100644 --- a/src/controllers/multi-tenancy/MultiTenancyController.ts +++ b/src/controllers/multi-tenancy/MultiTenancyController.ts @@ -1,6 +1,6 @@ import type { RestAgentModules, RestMultiTenantAgentModules } from '../../cliAgent' import type { Version } from '../examples' -import type { RecipientKeyOption, SchemaMetadata } from '../types' +import type { BitStringCredential, CredentialStatusList, RecipientKeyOption, SchemaMetadata } from '../types' import type { PolygonDidCreateOptions } from '@ayanworks/credo-polygon-w3c-module/build/dids' import type { AcceptProofRequestOptions, @@ -14,7 +14,10 @@ import type { ProofExchangeRecordProps, ProofsProtocolVersionType, Routing, + W3cCredentialRecord, + W3cJsonLdSignCredentialOptions, } from '@credo-ts/core' +import type { GenericRecord } from '@credo-ts/core/build/modules/generic-records/repository/GenericRecord' import type { IndyVdrDidCreateOptions, IndyVdrDidCreateResult } from '@credo-ts/indy-vdr' import type { QuestionAnswerRecord, ValidResponse } from '@credo-ts/question-answer' import type { TenantRecord } from '@credo-ts/tenants' @@ -49,7 +52,16 @@ import { QuestionAnswerRole, QuestionAnswerState } from '@credo-ts/question-answ import axios from 'axios' import * as fs from 'fs' -import { CredentialEnum, DidMethod, EndorserMode, Network, NetworkTypes, Role, SchemaError } from '../../enums/enum' +import { + BitStringCredentialStatusPurpose, + CredentialEnum, + DidMethod, + EndorserMode, + Network, + NetworkTypes, + Role, + SchemaError, +} from '../../enums/enum' import ErrorHandlingService from '../../errorHandlingService' import { ENDORSER_DID_NOT_PRESENT } from '../../errorMessages' import { @@ -59,6 +71,7 @@ import { PaymentRequiredError, UnprocessableEntityError, } from '../../errors' +import utils from '../../utils/credentialStatusList' import { SchemaId, CredentialDefinitionId, @@ -80,7 +93,9 @@ import { CreateProofRequestOobOptions, CreateOfferOobOptions, CreateSchemaInput, + SignCredentialPayload, } from '../types' +import { W3CRevocationController } from '../w3cRevocation/w3cRevocationController' import { Body, Controller, Delete, Get, Post, Query, Route, Tags, Path, Example, Security, Response } from 'tsoa' @@ -89,10 +104,11 @@ import { Body, Controller, Delete, Get, Post, Query, Route, Tags, Path, Example, @injectable() export class MultiTenancyController extends Controller { private readonly agent: Agent - - public constructor(agent: Agent) { + private readonly w3CRevocationController!: W3CRevocationController + public constructor(agent: Agent, w3CRevocationController: W3CRevocationController) { super() this.agent = agent + this.w3CRevocationController = w3CRevocationController } //create wallet @@ -1274,12 +1290,41 @@ export class MultiTenancyController extends Controller { let offer try { await this.agent.modules.tenants.withTenantAgent({ tenantId }, async (tenantAgent) => { - offer = await tenantAgent.credentials.offerCredential({ - connectionId: createOfferOptions.connectionId, - protocolVersion: createOfferOptions.protocolVersion as CredentialProtocolVersionType<[]>, - credentialFormats: createOfferOptions.credentialFormats, - autoAcceptCredential: createOfferOptions.autoAcceptCredential, - }) + const { credentialFormats, isRevocable, credentialSubjectUrl, statusPurpose } = createOfferOptions + + if (!credentialFormats.jsonld) { + offer = await this.agent.credentials.offerCredential(createOfferOptions) + return + } + + if (isRevocable) { + if (!credentialSubjectUrl) { + throw new BadRequestError('Please provide valid credentialSubjectUrl') + } + + const bitStringCredentialStatusPurpose = statusPurpose ?? BitStringCredentialStatusPurpose.REVOCATION + const credentialStatusData = { + credentialSubjectUrl, + statusPurpose: bitStringCredentialStatusPurpose, + } as unknown as CredentialStatusList + + const getIndex = await tenantAgent.genericRecords.findAllByQuery({ + statusListCredentialURL: credentialSubjectUrl, + }) + + const credentialStatus = await utils.getCredentialStatus(credentialStatusData, getIndex) + credentialFormats.jsonld.credential.credentialStatus = credentialStatus + + offer = await tenantAgent.credentials.offerCredential(createOfferOptions) + + await tenantAgent.genericRecords.save({ + content: { index: credentialStatus.statusListIndex }, + tags: { statusListCredentialURL: credentialStatus.statusListCredential }, + id: offer.id, + }) + } else { + offer = await tenantAgent.credentials.offerCredential(createOfferOptions) + } }) return offer @@ -1292,7 +1337,7 @@ export class MultiTenancyController extends Controller { @Post('/credentials/create-offer-oob/:tenantId') public async createOfferOob(@Path('tenantId') tenantId: string, @Body() createOfferOptions: CreateOfferOobOptions) { let createOfferOobRecord - + let offerOob try { let invitationDid: string | undefined await this.agent.modules.tenants.withTenantAgent({ tenantId }, async (tenantAgent) => { @@ -1323,12 +1368,44 @@ export class MultiTenancyController extends Controller { invitationDid = did.didState.did } - const offerOob = await tenantAgent.credentials.createOffer({ - protocolVersion: createOfferOptions.protocolVersion as CredentialProtocolVersionType<[]>, - credentialFormats: createOfferOptions.credentialFormats, - autoAcceptCredential: createOfferOptions.autoAcceptCredential, - comment: createOfferOptions.comment, - }) + if (createOfferOptions.credentialFormats.jsonld && createOfferOptions.isRevocable) { + if (!createOfferOptions.credentialSubjectUrl) { + throw new BadRequestError('Please provide valid credentialSubjectUrl') + } + + const bitStringCredentialStatusPurpose = + createOfferOptions.statusPurpose ?? BitStringCredentialStatusPurpose.REVOCATION + + const credentialStatusData = { + credentialSubjectUrl: createOfferOptions.credentialSubjectUrl, + statusPurpose: bitStringCredentialStatusPurpose, + } as unknown as CredentialStatusList + const getIndex = await this.agent.genericRecords.findAllByQuery({ + statusListCredentialURL: createOfferOptions.credentialSubjectUrl, + }) + const credentialStatus = await utils.getCredentialStatus(credentialStatusData, getIndex) + createOfferOptions.credentialFormats.jsonld.credential.credentialStatus = credentialStatus + + offerOob = await tenantAgent.credentials.createOffer({ + protocolVersion: createOfferOptions.protocolVersion as CredentialProtocolVersionType<[]>, + credentialFormats: createOfferOptions.credentialFormats, + autoAcceptCredential: createOfferOptions.autoAcceptCredential, + comment: createOfferOptions.comment, + }) + + await this.agent.genericRecords.save({ + content: { index: credentialStatus.statusListIndex }, + tags: { statusListCredentialURL: credentialStatus.statusListCredential }, + id: offerOob.credentialRecord.id, + }) + } else { + offerOob = await tenantAgent.credentials.createOffer({ + protocolVersion: createOfferOptions.protocolVersion as CredentialProtocolVersionType<[]>, + credentialFormats: createOfferOptions.credentialFormats, + autoAcceptCredential: createOfferOptions.autoAcceptCredential, + comment: createOfferOptions.comment, + }) + } const credentialMessage = offerOob.message const outOfBandRecord = await tenantAgent.oob.createInvitation({ @@ -1582,6 +1659,7 @@ export class MultiTenancyController extends Controller { const requestedCredentials = await tenantAgent.proofs.selectCredentialsForRequest({ proofRecordId, }) + const acceptProofRequest: AcceptProofRequestOptions = { proofRecordId, comment: request.comment, @@ -1888,4 +1966,90 @@ export class MultiTenancyController extends Controller { throw ErrorHandlingService.handle(error) } } + + @Security('apiKey') + @Post('/sign/bitstring-credential/:tenantId') + public async createBitstringStatusListCredential( + @Body() signCredentialPayload: SignCredentialPayload, + @Path('tenantId') tenantId: string + ): Promise { + try { + const data = await this.w3CRevocationController._createBitstringStatusListCredential(signCredentialPayload) + return await this.agent.modules.tenants.withTenantAgent({ tenantId }, async (tenantAgent) => { + const signCredential = await tenantAgent.w3cCredentials.signCredential( + data as unknown as W3cJsonLdSignCredentialOptions + ) + + return await tenantAgent.w3cCredentials.storeCredential({ credential: signCredential }) + }) + } catch (error) { + throw ErrorHandlingService.handle(error) + } + } + + @Security('apiKey') + @Post('/revoke-credential/:credentialId/:tenantId') + public async revokeW3C( + @Path('credentialId') credentialId: string, + @Path('tenantId') tenantId: string + ): Promise<{ + message: string + }> { + try { + return await this.agent.modules.tenants.withTenantAgent({ tenantId }, async (tenantAgent) => { + const credential = await tenantAgent.credentials.getFormatData(credentialId) + const { credentialIndex, statusListCredentialURL } = await this.w3CRevocationController._revokeW3C(credential) + const revocationId = `${statusListCredentialURL}::${credentialIndex}` + + await tenantAgent.credentials.sendRevocationNotification({ + credentialRecordId: credentialId, + revocationId, + revocationFormat: 'jsonld', + comment: `Your credential has been revoked.`, + }) + return { message: 'The credential has been successfully revoked.' } + }) + } catch (error) { + throw ErrorHandlingService.handle(error) + } + } + + @Security('apiKey') + @Get('bitstring/status-list/:bitCredentialStatusUrl/:tenantId') + public async getBitStringStatusListById( + @Path('bitCredentialStatusUrl') bitCredentialStatusUrl: string, + @Path('tenantId') tenantId: string + ): Promise<{ + bitStringCredential: BitStringCredential + getIndex: GenericRecord[] + }> { + try { + const bitStringCredential = await this.w3CRevocationController._getBitStringStatusListById(bitCredentialStatusUrl) + return await this.agent.modules.tenants.withTenantAgent({ tenantId }, async (tenantAgent) => { + const getIndex = await tenantAgent.genericRecords.findAllByQuery({ + statusListCredentialURL: bitCredentialStatusUrl, + }) + + return { + bitStringCredential, + getIndex, + } + }) + } catch (error) { + throw ErrorHandlingService.handle(error) + } + } + + @Security('apiKey') + @Get('bitstring/status-list/:tenantId') + public async getAllBitStringStatusList(@Path('tenantId') tenantId: string): Promise { + try { + return await this.agent.modules.tenants.withTenantAgent({ tenantId }, async (tenantAgent) => { + const getBitStringCredentialStatusList = await tenantAgent.w3cCredentials.getAllCredentialRecords() + return getBitStringCredentialStatusList + }) + } catch (error) { + throw ErrorHandlingService.handle(error) + } + } } diff --git a/src/controllers/types.ts b/src/controllers/types.ts index 3e425e63..3eb59d29 100644 --- a/src/controllers/types.ts +++ b/src/controllers/types.ts @@ -1,5 +1,5 @@ import type { RecordId, Version } from './examples' -import type { CustomHandshakeProtocol } from '../enums/enum' +import type { BitStringCredentialStatusPurpose, CustomHandshakeProtocol } from '../enums/enum' import type { AnonCredsCredentialFormat, LegacyIndyCredentialFormat } from '@credo-ts/anoncreds' import type { AutoAcceptCredential, @@ -88,12 +88,17 @@ export interface AcceptCredentialProposalOptions { comment?: string } +export type CredentialStatusType = 'BitstringStatusListEntry' + export interface CreateOfferOptions { protocolVersion: ProtocolVersion connectionId: RecordId credentialFormats: CredentialFormatPayload autoAcceptCredential?: AutoAcceptCredential comment?: string + statusPurpose?: BitStringCredentialStatusPurpose + credentialSubjectUrl?: string + isRevocable?: boolean } type CredentialFormatType = LegacyIndyCredentialFormat | JsonLdCredentialFormat | AnonCredsCredentialFormat @@ -110,6 +115,9 @@ export interface CreateOfferOobOptions { imageUrl?: string recipientKey?: string invitationDid?: string + isRevocable?: boolean + credentialSubjectUrl?: string + statusPurpose?: string } export interface CredentialCreateOfferOptions { credentialRecord: CredentialExchangeRecord @@ -388,3 +396,46 @@ export interface SchemaMetadata { * @example "ea4e5e69-fc04-465a-90d2-9f8ff78aa71d" */ export type ThreadId = string + +export interface StatusList { + decode({ encodedList }: { encodedList: any }): Promise + bitstring: any + length: any + setStatus(index: any, status: any): any + getStatus(index: any): any + encode(): Promise +} + +export interface SignCredentialPayload { + bitStringCredentialUrl: string + issuerDid: string + statusPurpose?: string + bitStringLength?: number +} + +export interface BitStringCredential { + credential: { + credentialSubject: { + id: string + type: string + encodedList: string + statusPurpose: string + } + } +} + +export interface IndexRecord { + content: { + index: string + } +} + +export interface CredentialStatusList { + credentialSubjectUrl: string + statusPurpose: BitStringCredentialStatusPurpose +} + +export interface OobOffer { + message: AgentMessage + credentialRecord: CredentialExchangeRecord +} diff --git a/src/controllers/w3cRevocation/w3cRevocationController.ts b/src/controllers/w3cRevocation/w3cRevocationController.ts new file mode 100644 index 00000000..fe34d86f --- /dev/null +++ b/src/controllers/w3cRevocation/w3cRevocationController.ts @@ -0,0 +1,236 @@ +import type { RestAgentModules } from '../../cliAgent' +import type { BitStringCredential } from '../types' +import type { AnonCredsCredentialFormat, LegacyIndyCredentialFormat } from '@credo-ts/anoncreds' +import type { + GetCredentialFormatDataReturn, + JsonLdCredentialFormat, + W3cCredentialRecord, + W3cJsonLdSignCredentialOptions, +} from '@credo-ts/core' +import type { GenericRecord } from '@credo-ts/core/build/modules/generic-records/repository/GenericRecord' + +import { Agent, ClaimFormat } from '@credo-ts/core' +import { injectable } from 'tsyringe' + +import { BitStringCredentialStatusPurpose } from '../../enums/enum' +import ErrorHandlingService from '../../errorHandlingService' +import { BadRequestError, InternalServerError } from '../../errors/errors' +import utils from '../../utils/credentialStatusList' +import { SignCredentialPayload } from '../types' + +import { Tags, Route, Controller, Post, Security, Body, Path, Get } from 'tsoa' + +@Tags('Status') +@Route('/w3c/revocation') +@Security('apiKey') +@injectable() +export class W3CRevocationController extends Controller { + private agent: Agent + + public constructor(agent: Agent) { + super() + this.agent = agent + } + + @Post('/sign/bitstring-credential') + public async createBitstringStatusListCredential( + @Body() signCredentialPayload: SignCredentialPayload + ): Promise { + try { + const data = await this._createBitstringStatusListCredential(signCredentialPayload) + + const signCredential = await this.agent.w3cCredentials.signCredential( + data as unknown as W3cJsonLdSignCredentialOptions + ) + + const storCredential = await this.agent.w3cCredentials.storeCredential({ credential: signCredential }) + + return storCredential + } catch (error) { + throw ErrorHandlingService.handle(error) + } + } + + @Post('/revoke-credential/:credentialId') + public async revokeW3C(@Path('credentialId') credentialId: string): Promise<{ + message: string + }> { + try { + const credential = await this.agent.credentials.getFormatData(credentialId) + const { credentialIndex, statusListCredentialURL } = await this._revokeW3C(credential) + const revocationId = `${statusListCredentialURL}::${credentialIndex}` + + await this.agent.credentials.sendRevocationNotification({ + credentialRecordId: credentialId, + revocationId, + revocationFormat: 'jsonld', + comment: `Your credential has been revoked.`, + }) + return { message: 'The credential has been successfully revoked.' } + } catch (error) { + throw ErrorHandlingService.handle(error) + } + } + + @Get('bitstring/status-list/:bitCredentialStatusUrl') + public async getBitStringStatusListById(@Path('bitCredentialStatusUrl') bitCredentialStatusUrl: string): Promise<{ + bitStringCredential: BitStringCredential + getIndex: GenericRecord[] + }> { + try { + const bitStringCredential = await this._getBitStringStatusListById(bitCredentialStatusUrl) + const getIndex = await this.agent.genericRecords.findAllByQuery({ + statusListCredentialURL: bitCredentialStatusUrl, + }) + + return { + bitStringCredential, + getIndex, + } + } catch (error) { + throw ErrorHandlingService.handle(error) + } + } + + @Get('bitstring/status-list/') + public async getAllBitStringStatusList(): Promise { + try { + const getBitStringCredentialStatusList = await this.agent.w3cCredentials.getAllCredentialRecords() + return getBitStringCredentialStatusList + } catch (error) { + throw ErrorHandlingService.handle(error) + } + } + + public async _createBitstringStatusListCredential(signCredentialPayload: SignCredentialPayload) { + try { + const { bitStringCredentialUrl, issuerDid, statusPurpose, bitStringLength } = signCredentialPayload + const bitStringStatusListPurpose = statusPurpose ?? BitStringCredentialStatusPurpose.REVOCATION + const bitStringStatusListCredentialListLength = bitStringLength ? bitStringLength : 131072 + const bitStringStatus = await utils.generateBitStringStatus(bitStringStatusListCredentialListLength) + const encodedList = await utils.encodeBitString(bitStringStatus) + const didIdentifier = issuerDid.split(':')[2] + const data = { + format: ClaimFormat.LdpVc, + credential: { + '@context': ['https://www.w3.org/2018/credentials/v1', 'https://w3id.org/vc/status-list/2021/v1'], + id: bitStringCredentialUrl, + type: ['VerifiableCredential', 'BitstringStatusListCredential'], + issuer: { + id: issuerDid, + }, + issuanceDate: new Date().toISOString(), + credentialSubject: { + id: bitStringCredentialUrl, + type: 'BitstringStatusList', + encodedList, + statusPurpose: bitStringStatusListPurpose, + }, + }, + verificationMethod: `${issuerDid}#${didIdentifier}`, + proofType: 'Ed25519Signature2018', + } + + await fetch(bitStringCredentialUrl, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ credentialsData: data }), + }) + + return data + } catch (error) { + throw ErrorHandlingService.handle(error) + } + } + + public async _revokeW3C( + credential: GetCredentialFormatDataReturn< + (LegacyIndyCredentialFormat | JsonLdCredentialFormat | AnonCredsCredentialFormat)[] + > + ) { + try { + let credentialIndex + let statusListCredentialURL + const revocationStatus = 1 + + if (!Array.isArray(credential.offer?.jsonld?.credential?.credentialStatus)) { + credentialIndex = credential.offer?.jsonld?.credential?.credentialStatus?.statusListIndex as string + statusListCredentialURL = credential.offer?.jsonld?.credential?.credentialStatus?.statusListCredential as string + } else { + credentialIndex = credential.offer?.jsonld?.credential?.credentialStatus[0].statusListIndex as string + statusListCredentialURL = credential.offer?.jsonld?.credential?.credentialStatus[0] + .statusListCredential as string + } + + const bitStringStatusListCredential = await fetch(statusListCredentialURL, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!bitStringStatusListCredential.ok) { + throw new InternalServerError(`${bitStringStatusListCredential.statusText}`) + } + + const bitStringCredential = (await bitStringStatusListCredential.json()) as BitStringCredential + const encodedBitString = bitStringCredential.credential.credentialSubject.encodedList + const decodeBitString = await utils.decodeBitSting(encodedBitString) + + const findBitStringIndex = decodeBitString.charAt(parseInt(credentialIndex)) + if (findBitStringIndex === revocationStatus.toString()) { + throw new BadRequestError('The credential already revoked') + } + + const updateBitString = + decodeBitString.slice(0, parseInt(credentialIndex)) + + revocationStatus + + decodeBitString.slice(parseInt(credentialIndex) + 1) + + const encodeUpdatedBitString = await utils.encodeBitString(updateBitString) + bitStringCredential.credential.credentialSubject.encodedList = encodeUpdatedBitString + await fetch(statusListCredentialURL, { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ credentialsData: bitStringCredential }), + }) + + return { credentialIndex, statusListCredentialURL } + } catch (error) { + throw ErrorHandlingService.handle(error) + } + } + + public async _getBitStringStatusListById(bitCredentialStatusUrl: string) { + try { + const validateUrl = await utils.isValidUrl(bitCredentialStatusUrl) + if (!validateUrl) { + throw new BadRequestError(`Please provide a bit string credential id`) + } + + const bitStringCredentialDetails = await fetch(bitCredentialStatusUrl, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!bitStringCredentialDetails.ok) { + throw new InternalServerError(`${bitStringCredentialDetails.statusText}`) + } + + const bitStringCredential = (await bitStringCredentialDetails.json()) as BitStringCredential + if (!bitStringCredential?.credential && !bitStringCredential?.credential?.credentialSubject) { + throw new BadRequestError(`Invalid credentialSubjectUrl`) + } + + return bitStringCredential + } catch (error) { + throw ErrorHandlingService.handle(error) + } + } +} diff --git a/src/controllers/w3cRevocation/w3cRevocationTypes.ts b/src/controllers/w3cRevocation/w3cRevocationTypes.ts new file mode 100644 index 00000000..e1ce210c --- /dev/null +++ b/src/controllers/w3cRevocation/w3cRevocationTypes.ts @@ -0,0 +1,25 @@ +import type { ClaimFormat, W3cCredentialSubject, W3cIssuer } from '@credo-ts/core' +import type { SingleOrArray } from '@credo-ts/core/build/utils' + +export interface StatusListCredential { + // Inside credential + // '@context': string[] + // id: string + // type: Array + // issuer: string | W3cIssuer + // issuanceDate: string + // credentialSubject: SingleOrArray + // Others + format: ClaimFormat.LdpVc + proofType: string + verificationMethod: string + credential: Credential +} +export interface Credential { + '@context': string[] + id: string + type: Array + issuer: string | W3cIssuer + issuanceDate: string + credentialSubject: SingleOrArray +} diff --git a/src/enums/enum.ts b/src/enums/enum.ts index 6468e3af..375ecd70 100644 --- a/src/enums/enum.ts +++ b/src/enums/enum.ts @@ -71,3 +71,9 @@ export declare enum CustomHandshakeProtocol { DidExchange = 'https://didcomm.org/didexchange/1.1', Connections = 'https://didcomm.org/connections/1.0', } + +export enum BitStringCredentialStatusPurpose { + REVOCATION = 'revocation', + SUSPENSION = 'suspension', + MESSAGE = 'message', +} diff --git a/src/lib/nonEsModule.ts b/src/lib/nonEsModule.ts new file mode 100644 index 00000000..22c87076 --- /dev/null +++ b/src/lib/nonEsModule.ts @@ -0,0 +1,3 @@ +export async function loadStatusList() { + return import('@digitalbazaar/vc-status-list') +} diff --git a/src/securityMiddleware.ts b/src/securityMiddleware.ts index 75654c49..08b82bae 100644 --- a/src/securityMiddleware.ts +++ b/src/securityMiddleware.ts @@ -20,6 +20,7 @@ export class SecurityMiddleware { { path: '/url/', method: 'GET' }, { path: '/multi-tenancy/url/', method: 'GET' }, { path: '/agent', method: 'GET' }, + { path: '/status', method: 'GET' }, ] // Check if authentication should be skipped for this route or controller diff --git a/src/types/vc-status-list.d.ts b/src/types/vc-status-list.d.ts new file mode 100644 index 00000000..77780e90 --- /dev/null +++ b/src/types/vc-status-list.d.ts @@ -0,0 +1,77 @@ +declare module '@digitalbazaar/vc-status-list' { + export class StatusList { + public static decode({ encodedList }: { encodedList: any }): Promise + public constructor({ length, buffer }?: { length: any; buffer: any }) + public bitstring: any + public length: any + public setStatus(index: any, status: any): any + public getStatus(index: any): any + public encode(): Promise + } + + export function createList({ length }: { length: any }): Promise + export function decodeList({ encodedList }: { encodedList: any }): Promise + /** + * Creates a StatusList Credential. + * + * @param {object} options - Options to use. + * @param {string} options.id - The id for StatusList Credential. + * @param {StatusList} options.list - An instance of StatusList. + * @param {string} options.statusPurpose - The purpose of the status entry. + * + * @returns {object} The resulting `StatusList Credential`. + */ + export function createCredential({ + id, + list, + statusPurpose, + }: { + id: string + list: StatusList + statusPurpose: string + }): object + export function checkStatus({ + credential, + documentLoader, + suite, + verifyStatusListCredential, + verifyMatchingIssuers, + }?: { + credential: any + documentLoader: any + suite: any + verifyStatusListCredential?: any + verifyMatchingIssuers?: any + }): Promise< + | { + verified: any + results: any + } + | { + verified: boolean + error: any + } + > + export function statusTypeMatches({ credential }?: { credential: any }): boolean + export function assertStatusList2021Context({ credential }?: { credential: any }): void + /** + * Gets the `credentialStatus` of a credential based on its status purpose + * (`statusPurpose`). + * + * @param {object} options - Options to use. + * @param {object} options.credential - A VC. + * @param {'revocation'|'suspension'} options.statusPurpose - A + * `statusPurpose`. + * + * @throws If the `credentialStatus` is invalid or missing. + * + * @returns {object} The resulting `credentialStatus`. + */ + export function getCredentialStatus({ + credential, + statusPurpose, + }?: { + credential: object + statusPurpose: 'revocation' | 'suspension' + }): object +} diff --git a/src/utils/ServerConfig.ts b/src/utils/ServerConfig.ts index 4f66c053..1f537faf 100644 --- a/src/utils/ServerConfig.ts +++ b/src/utils/ServerConfig.ts @@ -9,4 +9,5 @@ export interface ServerConfig { /* Socket server is used for sending events over websocket to clients */ socketServer?: Server schemaFileServerURL?: string + bitStringStatusListURL?: string } diff --git a/src/utils/credentialStatusList.ts b/src/utils/credentialStatusList.ts new file mode 100644 index 00000000..97c8b4ae --- /dev/null +++ b/src/utils/credentialStatusList.ts @@ -0,0 +1,144 @@ +import type { BitStringCredential, CredentialStatusList } from '../controllers/types' +import type { CredentialStatus } from '@credo-ts/core' +import type { GenericRecord } from '@credo-ts/core/build/modules/generic-records/repository/GenericRecord' + +import { randomInt } from 'crypto' +// import { promisify } from 'util' +// import * as zlib from 'zlib' +// eslint-disable-next-line import/no-extraneous-dependencies +import pako from 'pako' + +import ErrorHandlingService from '../errorHandlingService' +import { BadRequestError, ConflictError, InternalServerError } from '../errors/errors' + +async function generateBitStringStatus(length: number): Promise { + return Array.from({ length }, () => (randomInt(0, 2) === 1 ? '1' : '0')).join('') +} + +async function encodeBitString(bitString: string): Promise { + // const gzip = promisify(zlib.gzip) + // const buffer = Buffer.from(bitString, 'binary') + // const compressedBuffer = await gzip(buffer) + // return compressedBuffer.toString('base64') + + // 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') +} + +async function decodeBitSting(bitString: string): Promise { + // 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 +} + +async function isValidUrl(url: string) { + try { + new URL(url) + return true + } catch (err) { + return false + } +} + +async function getCredentialStatus( + credentialStatusList: CredentialStatusList, + getIndex: GenericRecord[] +): Promise { + try { + if (!credentialStatusList.credentialSubjectUrl || !credentialStatusList.statusPurpose) { + throw new BadRequestError(`Please provide valid credentialSubjectUrl and statusPurpose`) + } + const url = credentialStatusList.credentialSubjectUrl + const validateUrl = await isValidUrl(url) + if (!validateUrl) { + throw new BadRequestError(`Please provide a valid credentialSubjectUrl`) + } + + const bitStringStatusListCredential = await fetch(url, { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }) + + if (!bitStringStatusListCredential.ok) { + throw new InternalServerError(`${bitStringStatusListCredential.statusText}`) + } + + const bitStringCredential = (await bitStringStatusListCredential.json()) as BitStringCredential + + if (!bitStringCredential?.credential && !bitStringCredential?.credential?.credentialSubject) { + throw new BadRequestError(`Invalid credentialSubjectUrl`) + } + + if (bitStringCredential?.credential?.credentialSubject?.statusPurpose !== credentialStatusList?.statusPurpose) { + throw new BadRequestError( + `Invalid statusPurpose! Please provide valid statusPurpose. '${credentialStatusList.statusPurpose}'` + ) + } + + const encodedBitString = bitStringCredential.credential.credentialSubject.encodedList + const compressedBuffer = Uint8Array.from(atob(encodedBitString), (c) => c.charCodeAt(0)) + + const decompressedBuffer = pako.ungzip(compressedBuffer, { to: 'string' }) + // const decodedBitString = decompressedBuffer.toString('binary') + + let index + const arrayIndex: number[] = [] + if (getIndex.length === 0) { + index = decompressedBuffer.indexOf('0') + } else { + getIndex.find((record) => { + arrayIndex.push(Number(record.content.index)) + }) + + index = await getAvailableIndex(decompressedBuffer, arrayIndex) + } + + if (index === -1) { + throw new ConflictError( + `The provided bit string credential revocation list for ${credentialStatusList.credentialSubjectUrl} has been exhausted. Please supply a valid credentialSubjectUrl.` + ) + } + + const credentialStatus = { + id: `${credentialStatusList.credentialSubjectUrl}#${index}`, + type: 'BitstringStatusListEntry', + statusPurpose: credentialStatusList.statusPurpose, + statusListIndex: index.toString(), + statusListCredential: credentialStatusList.credentialSubjectUrl, + } as unknown as CredentialStatus + + return credentialStatus + } catch (error) { + throw ErrorHandlingService.handle(error) + } +} + +function getAvailableIndex(str: string, usedIndices: number[]) { + // Find all indices of the character '0' + const indices = [] + for (let i = 0; i < str.length; i++) { + if (str[i] === '0') { + indices.push(i) + } + } + + // Find the first available index that is not in the usedIndices array + for (const index of indices) { + if (!usedIndices.includes(index)) { + return index + } + } + + // If no available index is found, return -1 or any indication of 'not found' + return -1 +} + +export default { getCredentialStatus, generateBitStringStatus, encodeBitString, decodeBitSting, isValidUrl } diff --git a/tsconfig.build.json b/tsconfig.build.json index 0b81e7f6..c425bec3 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -12,7 +12,9 @@ "resolveJsonModule": true, "experimentalDecorators": true, "emitDecoratorMetadata": true, + "moduleResolution": "node", "types": ["jest"], + "typeRoots": ["node_modules/@types", "src/types"], "outDir": "./build" }, "include": ["src/**/*", "src/routes"], diff --git a/tsconfig.json b/tsconfig.json index 49cb417d..44c85af3 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -12,7 +12,8 @@ "lib": ["ES2021.Promise"], "experimentalDecorators": true, "emitDecoratorMetadata": true, - "types": ["jest", "node"] + "types": ["jest", "node"], + "typeRoots": ["src/types", "node_modules/@types"], }, "exclude": ["node_modules", "build"] }