From 664c891ddd758aa40ac25ad2c1a7bf8eb4ac6a98 Mon Sep 17 00:00:00 2001 From: Ariel Gentile Date: Tue, 21 Nov 2023 23:32:16 -0300 Subject: [PATCH] feat(anoncreds): issue revocable credentials (#1427) Signed-off-by: Ariel Gentile --- demo/src/Faber.ts | 1 + .../src/services/AnonCredsRsIssuerService.ts | 190 +++- .../services/AnonCredsRsVerifierService.ts | 101 +- .../tests/InMemoryTailsFileService.ts | 58 + .../anoncreds-rs/tests/LocalDidResolver.ts | 30 + .../anoncreds-rs/tests/anoncreds-flow.test.ts | 555 +++++---- packages/anoncreds-rs/tests/anoncredsSetup.ts | 561 +++++++++ .../v2-credential-revocation.e2e.test.ts | 237 ++++ .../tests/v2-credentials.e2e.test.ts | 674 +++++++++++ .../anoncreds-rs/tests/v2-proofs.e2e.test.ts | 1007 +++++++++++++++++ packages/anoncreds/src/AnonCredsApi.ts | 279 ++++- packages/anoncreds/src/AnonCredsApiOptions.ts | 28 +- packages/anoncreds/src/AnonCredsModule.ts | 4 + .../anoncreds/src/AnonCredsModuleConfig.ts | 14 + .../src/__tests__/AnonCredsModule.test.ts | 8 +- .../src/formats/AnonCredsCredentialFormat.ts | 4 + .../AnonCredsCredentialFormatService.ts | 105 +- .../src/formats/AnonCredsProofFormat.ts | 1 + .../formats/AnonCredsProofFormatService.ts | 4 +- .../formats/LegacyIndyProofFormatService.ts | 3 +- ...vocationRegistryDefinitionPrivateRecord.ts | 59 + ...tionRegistryDefinitionPrivateRepository.ts | 36 + ...CredsRevocationRegistryDefinitionRecord.ts | 46 + ...sRevocationRegistryDefinitionRepository.ts | 31 + ...onRegistryDefinitionRecordMetadataTypes.ts | 11 + packages/anoncreds/src/repository/index.ts | 4 + .../src/services/AnonCredsIssuerService.ts | 21 +- .../services/AnonCredsIssuerServiceOptions.ts | 46 +- packages/anoncreds/src/services/index.ts | 1 + .../services/registry/AnonCredsRegistry.ts | 30 +- .../RevocationRegistryDefinitionOptions.ts | 43 +- .../registry/RevocationStatusListOptions.ts | 48 +- .../services/tails/BasicTailsFileService.ts | 88 ++ .../src/services/tails/TailsFileService.ts | 47 + .../anoncreds/src/services/tails/index.ts | 2 + .../src/utils/createRequestFromPreview.ts | 9 +- .../src/utils/getRevocationRegistries.ts | 63 +- packages/anoncreds/src/utils/index.ts | 2 +- packages/anoncreds/src/utils/tails.ts | 57 - packages/anoncreds/src/utils/timestamp.ts | 2 + .../tests/InMemoryAnonCredsRegistry.ts | 111 +- packages/anoncreds/tests/anoncreds.test.ts | 1 + .../anoncreds/tests/legacyAnonCredsSetup.ts | 4 +- .../services/CheqdAnonCredsRegistry.ts | 10 + .../src/modules/credentials/CredentialsApi.ts | 54 +- .../credentials/CredentialsApiOptions.ts | 11 + .../services/RevocationNotificationService.ts | 38 +- .../RevocationNotificationServiceOptions.ts | 6 + .../revocation-notification/services/index.ts | 1 + .../util/revocationIdentifier.ts | 5 + packages/core/tests/helpers.ts | 42 + .../services/IndySdkAnonCredsRegistry.ts | 11 + .../services/IndySdkIssuerService.ts | 36 +- .../src/anoncreds/IndyVdrAnonCredsRegistry.ts | 13 +- samples/tails/.gitignore | 1 + samples/tails/FullTailsFileService.ts | 41 + samples/tails/README.md | 5 + samples/tails/package.json | 27 + samples/tails/server.ts | 132 +++ samples/tails/tsconfig.json | 6 + samples/tails/yarn.lock | 335 ++++++ yarn.lock | 57 +- 62 files changed, 5039 insertions(+), 418 deletions(-) create mode 100644 packages/anoncreds-rs/tests/InMemoryTailsFileService.ts create mode 100644 packages/anoncreds-rs/tests/LocalDidResolver.ts create mode 100644 packages/anoncreds-rs/tests/anoncredsSetup.ts create mode 100644 packages/anoncreds-rs/tests/v2-credential-revocation.e2e.test.ts create mode 100644 packages/anoncreds-rs/tests/v2-credentials.e2e.test.ts create mode 100644 packages/anoncreds-rs/tests/v2-proofs.e2e.test.ts create mode 100644 packages/anoncreds/src/repository/AnonCredsRevocationRegistryDefinitionPrivateRecord.ts create mode 100644 packages/anoncreds/src/repository/AnonCredsRevocationRegistryDefinitionPrivateRepository.ts create mode 100644 packages/anoncreds/src/repository/AnonCredsRevocationRegistryDefinitionRecord.ts create mode 100644 packages/anoncreds/src/repository/AnonCredsRevocationRegistryDefinitionRepository.ts create mode 100644 packages/anoncreds/src/repository/anonCredsRevocationRegistryDefinitionRecordMetadataTypes.ts create mode 100644 packages/anoncreds/src/services/tails/BasicTailsFileService.ts create mode 100644 packages/anoncreds/src/services/tails/TailsFileService.ts create mode 100644 packages/anoncreds/src/services/tails/index.ts delete mode 100644 packages/anoncreds/src/utils/tails.ts create mode 100644 packages/anoncreds/src/utils/timestamp.ts create mode 100644 packages/core/src/modules/credentials/protocol/revocation-notification/services/RevocationNotificationServiceOptions.ts create mode 100644 samples/tails/.gitignore create mode 100644 samples/tails/FullTailsFileService.ts create mode 100644 samples/tails/README.md create mode 100644 samples/tails/package.json create mode 100644 samples/tails/server.ts create mode 100644 samples/tails/tsconfig.json create mode 100644 samples/tails/yarn.lock diff --git a/demo/src/Faber.ts b/demo/src/Faber.ts index 91befb8918..a0bfaff828 100644 --- a/demo/src/Faber.ts +++ b/demo/src/Faber.ts @@ -176,6 +176,7 @@ export class Faber extends BaseAgent { issuerId: this.anonCredsIssuerId, tag: 'latest', }, + supportRevocation: false, options: { endorserMode: 'internal', endorserDid: this.anonCredsIssuerId, diff --git a/packages/anoncreds-rs/src/services/AnonCredsRsIssuerService.ts b/packages/anoncreds-rs/src/services/AnonCredsRsIssuerService.ts index 383c6e94e7..f4c666de56 100644 --- a/packages/anoncreds-rs/src/services/AnonCredsRsIssuerService.ts +++ b/packages/anoncreds-rs/src/services/AnonCredsRsIssuerService.ts @@ -10,6 +10,12 @@ import type { AnonCredsCredentialDefinition, CreateCredentialDefinitionReturn, AnonCredsCredential, + CreateRevocationRegistryDefinitionOptions, + CreateRevocationRegistryDefinitionReturn, + AnonCredsRevocationRegistryDefinition, + CreateRevocationStatusListOptions, + AnonCredsRevocationStatusList, + UpdateRevocationStatusListOptions, } from '@aries-framework/anoncreds' import type { AgentContext } from '@aries-framework/core' import type { CredentialDefinitionPrivate, JsonObject, KeyCorrectnessProof } from '@hyperledger/anoncreds-shared' @@ -22,9 +28,21 @@ import { AnonCredsKeyCorrectnessProofRepository, AnonCredsCredentialDefinitionPrivateRepository, AnonCredsCredentialDefinitionRepository, + AnonCredsRevocationRegistryDefinitionRepository, + AnonCredsRevocationRegistryDefinitionPrivateRepository, + AnonCredsRevocationRegistryState, } from '@aries-framework/anoncreds' import { injectable, AriesFrameworkError } from '@aries-framework/core' -import { Credential, CredentialDefinition, CredentialOffer, Schema } from '@hyperledger/anoncreds-shared' +import { + RevocationStatusList, + RevocationRegistryDefinitionPrivate, + RevocationRegistryDefinition, + CredentialRevocationConfig, + Credential, + CredentialDefinition, + CredentialOffer, + Schema, +} from '@hyperledger/anoncreds-shared' import { AnonCredsRsError } from '../errors/AnonCredsRsError' @@ -83,6 +101,118 @@ export class AnonCredsRsIssuerService implements AnonCredsIssuerService { } } + public async createRevocationRegistryDefinition( + agentContext: AgentContext, + options: CreateRevocationRegistryDefinitionOptions + ): Promise { + const { tag, issuerId, credentialDefinition, credentialDefinitionId, maximumCredentialNumber, tailsDirectoryPath } = + options + + let createReturnObj: + | { + revocationRegistryDefinition: RevocationRegistryDefinition + revocationRegistryDefinitionPrivate: RevocationRegistryDefinitionPrivate + } + | undefined + try { + createReturnObj = RevocationRegistryDefinition.create({ + credentialDefinition: credentialDefinition as unknown as JsonObject, + credentialDefinitionId, + issuerId, + maximumCredentialNumber, + revocationRegistryType: 'CL_ACCUM', + tag, + tailsDirectoryPath, + }) + + return { + revocationRegistryDefinition: + createReturnObj.revocationRegistryDefinition.toJson() as unknown as AnonCredsRevocationRegistryDefinition, + revocationRegistryDefinitionPrivate: createReturnObj.revocationRegistryDefinitionPrivate.toJson(), + } + } finally { + createReturnObj?.revocationRegistryDefinition.handle.clear() + createReturnObj?.revocationRegistryDefinitionPrivate.handle.clear() + } + } + + public async createRevocationStatusList( + agentContext: AgentContext, + options: CreateRevocationStatusListOptions + ): Promise { + const { issuerId, revocationRegistryDefinitionId, revocationRegistryDefinition } = options + + const credentialDefinitionRecord = await agentContext.dependencyManager + .resolve(AnonCredsCredentialDefinitionRepository) + .getByCredentialDefinitionId(agentContext, revocationRegistryDefinition.credDefId) + + const revocationRegistryDefinitionPrivateRecord = await agentContext.dependencyManager + .resolve(AnonCredsRevocationRegistryDefinitionPrivateRepository) + .getByRevocationRegistryDefinitionId(agentContext, revocationRegistryDefinitionId) + + let revocationStatusList: RevocationStatusList | undefined + try { + revocationStatusList = RevocationStatusList.create({ + issuanceByDefault: true, + revocationRegistryDefinitionId, + credentialDefinition: credentialDefinitionRecord.credentialDefinition as unknown as JsonObject, + revocationRegistryDefinition: revocationRegistryDefinition as unknown as JsonObject, + revocationRegistryDefinitionPrivate: revocationRegistryDefinitionPrivateRecord.value as unknown as JsonObject, + issuerId, + }) + + return revocationStatusList.toJson() as unknown as AnonCredsRevocationStatusList + } finally { + revocationStatusList?.handle.clear() + } + } + + public async updateRevocationStatusList( + agentContext: AgentContext, + options: UpdateRevocationStatusListOptions + ): Promise { + const { revocationStatusList, revocationRegistryDefinition, issued, revoked, timestamp, tailsFilePath } = options + + let updatedRevocationStatusList: RevocationStatusList | undefined + let revocationRegistryDefinitionObj: RevocationRegistryDefinition | undefined + + try { + updatedRevocationStatusList = RevocationStatusList.fromJson(revocationStatusList as unknown as JsonObject) + + if (timestamp && !issued && !revoked) { + updatedRevocationStatusList.updateTimestamp({ + timestamp, + }) + } else { + const credentialDefinitionRecord = await agentContext.dependencyManager + .resolve(AnonCredsCredentialDefinitionRepository) + .getByCredentialDefinitionId(agentContext, revocationRegistryDefinition.credDefId) + + const revocationRegistryDefinitionPrivateRecord = await agentContext.dependencyManager + .resolve(AnonCredsRevocationRegistryDefinitionPrivateRepository) + .getByRevocationRegistryDefinitionId(agentContext, revocationStatusList.revRegDefId) + + revocationRegistryDefinitionObj = RevocationRegistryDefinition.fromJson({ + ...revocationRegistryDefinition, + value: { ...revocationRegistryDefinition.value, tailsLocation: tailsFilePath }, + } as unknown as JsonObject) + updatedRevocationStatusList.update({ + credentialDefinition: credentialDefinitionRecord.credentialDefinition as unknown as JsonObject, + revocationRegistryDefinition: revocationRegistryDefinitionObj, + revocationRegistryDefinitionPrivate: revocationRegistryDefinitionPrivateRecord.value, + issued: options.issued, + revoked: options.revoked, + timestamp: timestamp ?? -1, // FIXME: this should be fixed in anoncreds-rs wrapper + }) + } + + return updatedRevocationStatusList.toJson() as unknown as AnonCredsRevocationStatusList + } finally { + updatedRevocationStatusList?.handle.clear() + revocationRegistryDefinitionObj?.handle.clear() + } + } + public async createCredentialOffer( agentContext: AgentContext, options: CreateCredentialOfferOptions @@ -132,14 +262,28 @@ export class AnonCredsRsIssuerService implements AnonCredsIssuerService { agentContext: AgentContext, options: CreateCredentialOptions ): Promise { - const { tailsFilePath, credentialOffer, credentialRequest, credentialValues, revocationRegistryId } = options + const { + credentialOffer, + credentialRequest, + credentialValues, + revocationRegistryDefinitionId, + revocationStatusList, + revocationRegistryIndex, + } = options + + const definedRevocationOptions = [ + revocationRegistryDefinitionId, + revocationStatusList, + revocationRegistryIndex, + ].filter((e) => e !== undefined) + if (definedRevocationOptions.length > 0 && definedRevocationOptions.length < 3) { + throw new AriesFrameworkError( + 'Revocation requires all of revocationRegistryDefinitionId, revocationRegistryIndex and revocationStatusList' + ) + } let credential: Credential | undefined try { - if (revocationRegistryId || tailsFilePath) { - throw new AriesFrameworkError('Revocation not supported yet') - } - const attributeRawValues: Record = {} const attributeEncodedValues: Record = {} @@ -172,14 +316,46 @@ export class AnonCredsRsIssuerService implements AnonCredsIssuerService { } } + let revocationConfiguration: CredentialRevocationConfig | undefined + if (revocationRegistryDefinitionId && revocationStatusList && revocationRegistryIndex) { + const revocationRegistryDefinitionRecord = await agentContext.dependencyManager + .resolve(AnonCredsRevocationRegistryDefinitionRepository) + .getByRevocationRegistryDefinitionId(agentContext, revocationRegistryDefinitionId) + + const revocationRegistryDefinitionPrivateRecord = await agentContext.dependencyManager + .resolve(AnonCredsRevocationRegistryDefinitionPrivateRepository) + .getByRevocationRegistryDefinitionId(agentContext, revocationRegistryDefinitionId) + + if ( + revocationRegistryIndex >= revocationRegistryDefinitionRecord.revocationRegistryDefinition.value.maxCredNum + ) { + revocationRegistryDefinitionPrivateRecord.state = AnonCredsRevocationRegistryState.Full + } + + revocationConfiguration = new CredentialRevocationConfig({ + registryDefinition: RevocationRegistryDefinition.fromJson( + revocationRegistryDefinitionRecord.revocationRegistryDefinition as unknown as JsonObject + ), + registryDefinitionPrivate: RevocationRegistryDefinitionPrivate.fromJson( + revocationRegistryDefinitionPrivateRecord.value + ), + statusList: RevocationStatusList.fromJson(revocationStatusList as unknown as JsonObject), + registryIndex: revocationRegistryIndex, + }) + } credential = Credential.create({ credentialDefinition: credentialDefinitionRecord.credentialDefinition as unknown as JsonObject, credentialOffer: credentialOffer as unknown as JsonObject, credentialRequest: credentialRequest as unknown as JsonObject, - revocationRegistryId, + revocationRegistryId: revocationRegistryDefinitionId, attributeEncodedValues, attributeRawValues, credentialDefinitionPrivate: credentialDefinitionPrivateRecord.value, + revocationConfiguration, + // FIXME: duplicated input parameter? + revocationStatusList: revocationStatusList + ? RevocationStatusList.fromJson(revocationStatusList as unknown as JsonObject) + : undefined, }) return { diff --git a/packages/anoncreds-rs/src/services/AnonCredsRsVerifierService.ts b/packages/anoncreds-rs/src/services/AnonCredsRsVerifierService.ts index 26573309ff..d9f615a964 100644 --- a/packages/anoncreds-rs/src/services/AnonCredsRsVerifierService.ts +++ b/packages/anoncreds-rs/src/services/AnonCredsRsVerifierService.ts @@ -1,7 +1,14 @@ -import type { AnonCredsVerifierService, VerifyProofOptions } from '@aries-framework/anoncreds' +import type { + AnonCredsNonRevokedInterval, + AnonCredsProof, + AnonCredsProofRequest, + AnonCredsVerifierService, + VerifyProofOptions, +} from '@aries-framework/anoncreds' import type { AgentContext } from '@aries-framework/core' -import type { JsonObject } from '@hyperledger/anoncreds-shared' +import type { JsonObject, NonRevokedIntervalOverride } from '@hyperledger/anoncreds-shared' +import { AnonCredsRegistryService } from '@aries-framework/anoncreds' import { injectable } from '@aries-framework/core' import { Presentation } from '@hyperledger/anoncreds-shared' @@ -12,6 +19,16 @@ export class AnonCredsRsVerifierService implements AnonCredsVerifierService { let presentation: Presentation | undefined try { + // Check that provided timestamps correspond to the active ones from the VDR. If they are and differ from the originally + // requested ones, create overrides for anoncreds-rs to consider them valid + const { verified, nonRevokedIntervalOverrides } = await this.verifyTimestamps(agentContext, proof, proofRequest) + + // No need to call anoncreds-rs as we already know that the proof will not be valid + if (!verified) { + agentContext.config.logger.debug('Invalid timestamps for provided identifiers') + return false + } + presentation = Presentation.fromJson(proof as unknown as JsonObject) const rsCredentialDefinitions: Record = {} @@ -41,9 +58,89 @@ export class AnonCredsRsVerifierService implements AnonCredsVerifierService { schemas: rsSchemas, revocationRegistryDefinitions, revocationStatusLists: lists, + nonRevokedIntervalOverrides, }) } finally { presentation?.handle.clear() } } + + private async verifyTimestamps( + agentContext: AgentContext, + proof: AnonCredsProof, + proofRequest: AnonCredsProofRequest + ): Promise<{ verified: boolean; nonRevokedIntervalOverrides?: NonRevokedIntervalOverride[] }> { + const nonRevokedIntervalOverrides: NonRevokedIntervalOverride[] = [] + + // Override expected timestamps if the requested ones don't exacly match the values from VDR + const globalNonRevokedInterval = proofRequest.non_revoked + + const requestedNonRevokedRestrictions: { + nonRevokedInterval: AnonCredsNonRevokedInterval + schemaId?: string + credentialDefinitionId?: string + revocationRegistryDefinitionId?: string + }[] = [] + + for (const value of [ + ...Object.values(proofRequest.requested_attributes), + ...Object.values(proofRequest.requested_predicates), + ]) { + const nonRevokedInterval = value.non_revoked ?? globalNonRevokedInterval + if (nonRevokedInterval) { + value.restrictions?.forEach((restriction) => + requestedNonRevokedRestrictions.push({ + nonRevokedInterval, + schemaId: restriction.schema_id, + credentialDefinitionId: restriction.cred_def_id, + revocationRegistryDefinitionId: restriction.rev_reg_id, + }) + ) + } + } + + for (const identifier of proof.identifiers) { + if (!identifier.timestamp || !identifier.rev_reg_id) { + continue + } + const relatedNonRevokedRestrictionItem = requestedNonRevokedRestrictions.find( + (item) => + item.revocationRegistryDefinitionId === item.revocationRegistryDefinitionId || + item.credentialDefinitionId === identifier.cred_def_id || + item.schemaId === item.schemaId + ) + + const requestedFrom = relatedNonRevokedRestrictionItem?.nonRevokedInterval.from + if (requestedFrom && requestedFrom > identifier.timestamp) { + // Check VDR if the active revocation status list at requestedFrom was the one from provided timestamp. + // If it matches, add to the override list + const registry = agentContext.dependencyManager + .resolve(AnonCredsRegistryService) + .getRegistryForIdentifier(agentContext, identifier.rev_reg_id) + const { revocationStatusList } = await registry.getRevocationStatusList( + agentContext, + identifier.rev_reg_id, + requestedFrom + ) + const vdrTimestamp = revocationStatusList?.timestamp + if (vdrTimestamp && vdrTimestamp === identifier.timestamp) { + nonRevokedIntervalOverrides.push({ + overrideRevocationStatusListTimestamp: identifier.timestamp, + requestedFromTimestamp: requestedFrom, + revocationRegistryDefinitionId: identifier.rev_reg_id, + }) + } else { + agentContext.config.logger.debug( + `VDR timestamp for ${requestedFrom} does not correspond to the one provided in proof identifiers. Expected: ${identifier.timestamp} and received ${vdrTimestamp}` + ) + return { verified: false } + } + } + } + + return { + verified: true, + nonRevokedIntervalOverrides: nonRevokedIntervalOverrides.length ? nonRevokedIntervalOverrides : undefined, + } + } } diff --git a/packages/anoncreds-rs/tests/InMemoryTailsFileService.ts b/packages/anoncreds-rs/tests/InMemoryTailsFileService.ts new file mode 100644 index 0000000000..32cb2d48f4 --- /dev/null +++ b/packages/anoncreds-rs/tests/InMemoryTailsFileService.ts @@ -0,0 +1,58 @@ +import type { AnonCredsRevocationRegistryDefinition } from '@aries-framework/anoncreds' +import type { AgentContext, FileSystem } from '@aries-framework/core' + +import { BasicTailsFileService } from '@aries-framework/anoncreds' +import { InjectionSymbols } from '@aries-framework/core' + +export class InMemoryTailsFileService extends BasicTailsFileService { + private tailsFilePaths: Record = {} + + public async uploadTailsFile( + agentContext: AgentContext, + options: { + revocationRegistryDefinition: AnonCredsRevocationRegistryDefinition + } + ): Promise { + this.tailsFilePaths[options.revocationRegistryDefinition.value.tailsHash] = + options.revocationRegistryDefinition.value.tailsLocation + + return options.revocationRegistryDefinition.value.tailsHash + } + + public async getTailsFile( + agentContext: AgentContext, + options: { + revocationRegistryDefinition: AnonCredsRevocationRegistryDefinition + } + ): Promise { + const { revocationRegistryDefinition } = options + const { tailsLocation, tailsHash } = revocationRegistryDefinition.value + + try { + agentContext.config.logger.debug( + `Checking to see if tails file for URL ${revocationRegistryDefinition.value.tailsLocation} has been stored in the FileSystem` + ) + + // hash is used as file identifier + const tailsExists = await this.tailsFileExists(agentContext, tailsHash) + const tailsFilePath = await this.getTailsFilePath(agentContext, tailsHash) + agentContext.config.logger.debug( + `Tails file for ${tailsLocation} ${tailsExists ? 'is stored' : 'is not stored'} at ${tailsFilePath}` + ) + + if (!tailsExists) { + agentContext.config.logger.debug(`Retrieving tails file from URL ${tailsLocation}`) + const fileSystem = agentContext.dependencyManager.resolve(InjectionSymbols.FileSystem) + await fileSystem.downloadToFile(tailsLocation, tailsFilePath) + agentContext.config.logger.debug(`Saved tails file to FileSystem at path ${tailsFilePath}`) + } + + return tailsFilePath + } catch (error) { + agentContext.config.logger.error(`Error while retrieving tails file from URL ${tailsLocation}`, { + error, + }) + throw error + } + } +} diff --git a/packages/anoncreds-rs/tests/LocalDidResolver.ts b/packages/anoncreds-rs/tests/LocalDidResolver.ts new file mode 100644 index 0000000000..1f50c1b3aa --- /dev/null +++ b/packages/anoncreds-rs/tests/LocalDidResolver.ts @@ -0,0 +1,30 @@ +import type { DidResolutionResult, DidResolver, AgentContext } from '@aries-framework/core' + +import { DidsApi } from '@aries-framework/core' + +export class LocalDidResolver implements DidResolver { + public readonly supportedMethods = ['sov', 'indy'] + + public async resolve(agentContext: AgentContext, did: string): Promise { + const didDocumentMetadata = {} + + const didsApi = agentContext.dependencyManager.resolve(DidsApi) + + const didRecord = (await didsApi.getCreatedDids()).find((record) => record.did === did) + if (!didRecord) { + return { + didDocument: null, + didDocumentMetadata, + didResolutionMetadata: { + error: 'notFound', + message: `resolver_error: Unable to resolve did '${did}'`, + }, + } + } + return { + didDocument: didRecord.didDocument ?? null, + didDocumentMetadata, + didResolutionMetadata: { contentType: 'application/did+ld+json' }, + } + } +} diff --git a/packages/anoncreds-rs/tests/anoncreds-flow.test.ts b/packages/anoncreds-rs/tests/anoncreds-flow.test.ts index f3ac963ab2..06481055f4 100644 --- a/packages/anoncreds-rs/tests/anoncreds-flow.test.ts +++ b/packages/anoncreds-rs/tests/anoncreds-flow.test.ts @@ -2,6 +2,11 @@ import type { AnonCredsCredentialRequest } from '@aries-framework/anoncreds' import type { Wallet } from '@aries-framework/core' import { + AnonCredsRevocationRegistryDefinitionPrivateRecord, + AnonCredsRevocationRegistryDefinitionPrivateRepository, + AnonCredsRevocationRegistryDefinitionRecord, + AnonCredsRevocationRegistryDefinitionRepository, + AnonCredsRevocationRegistryState, AnonCredsModuleConfig, AnonCredsHolderServiceSymbol, AnonCredsIssuerServiceSymbol, @@ -31,15 +36,20 @@ import { Subject } from 'rxjs' import { InMemoryStorageService } from '../../../tests/InMemoryStorageService' import { AnonCredsRegistryService } from '../../anoncreds/src/services/registry/AnonCredsRegistryService' +import { dateToTimestamp } from '../../anoncreds/src/utils/timestamp' import { InMemoryAnonCredsRegistry } from '../../anoncreds/tests/InMemoryAnonCredsRegistry' import { agentDependencies, getAgentConfig, getAgentContext } from '../../core/tests/helpers' import { AnonCredsRsHolderService } from '../src/services/AnonCredsRsHolderService' import { AnonCredsRsIssuerService } from '../src/services/AnonCredsRsIssuerService' import { AnonCredsRsVerifierService } from '../src/services/AnonCredsRsVerifierService' +import { InMemoryTailsFileService } from './InMemoryTailsFileService' + const registry = new InMemoryAnonCredsRegistry() +const tailsFileService = new InMemoryTailsFileService() const anonCredsModuleConfig = new AnonCredsModuleConfig({ registries: [registry], + tailsFileService, }) const agentConfig = getAgentConfig('AnonCreds format services using anoncreds-rs') @@ -54,6 +64,7 @@ const agentContext = getAgentContext({ registerInstances: [ [InjectionSymbols.Stop$, new Subject()], [InjectionSymbols.AgentDependencies, agentDependencies], + [InjectionSymbols.FileSystem, new agentDependencies.FileSystem()], [InjectionSymbols.StorageService, inMemoryStorageService], [AnonCredsIssuerServiceSymbol, anonCredsIssuerService], [AnonCredsHolderServiceSymbol, anonCredsHolderService], @@ -71,289 +82,373 @@ const anoncredsProofFormatService = new AnonCredsProofFormatService() const indyDid = 'did:indy:local:LjgpST2rjsoxYegQDRm7EL' describe('AnonCreds format services using anoncreds-rs', () => { + afterEach(() => { + inMemoryStorageService.records = {} + }) + test('issuance and verification flow starting from proposal without negotiation and without revocation', async () => { - const schema = await anonCredsIssuerService.createSchema(agentContext, { - attrNames: ['name', 'age'], - issuerId: indyDid, - name: 'Employee Credential', - version: '1.0.0', + await anonCredsFlowTest({ issuerId: indyDid, revocable: false }) + }) + + test('issuance and verification flow starting from proposal without negotiation and with revocation', async () => { + await anonCredsFlowTest({ issuerId: indyDid, revocable: true }) + }) +}) + +async function anonCredsFlowTest(options: { issuerId: string; revocable: boolean }) { + const { issuerId, revocable } = options + + const schema = await anonCredsIssuerService.createSchema(agentContext, { + attrNames: ['name', 'age'], + issuerId, + name: 'Employee Credential', + version: '1.0.0', + }) + + const { schemaState } = await registry.registerSchema(agentContext, { + schema, + options: {}, + }) + + if (!schemaState.schema || !schemaState.schemaId) { + throw new Error('Failed to create schema') + } + + await agentContext.dependencyManager.resolve(AnonCredsSchemaRepository).save( + agentContext, + new AnonCredsSchemaRecord({ + schema: schemaState.schema, + schemaId: schemaState.schemaId, + methodName: 'inMemory', }) + ) - const { schemaState } = await registry.registerSchema(agentContext, { + const { credentialDefinition, credentialDefinitionPrivate, keyCorrectnessProof } = + await anonCredsIssuerService.createCredentialDefinition(agentContext, { + issuerId: indyDid, + schemaId: schemaState.schemaId as string, schema, - options: {}, + tag: 'Employee Credential', + supportRevocation: revocable, + }) + + const { credentialDefinitionState } = await registry.registerCredentialDefinition(agentContext, { + credentialDefinition, + options: {}, + }) + + if (!credentialDefinitionState.credentialDefinition || !credentialDefinitionState.credentialDefinitionId) { + throw new Error('Failed to create credential definition') + } + + if (!credentialDefinitionPrivate || !keyCorrectnessProof) { + throw new Error('Failed to get private part of credential definition') + } + + await agentContext.dependencyManager.resolve(AnonCredsCredentialDefinitionRepository).save( + agentContext, + new AnonCredsCredentialDefinitionRecord({ + credentialDefinition: credentialDefinitionState.credentialDefinition, + credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, + methodName: 'inMemory', + }) + ) + + await agentContext.dependencyManager.resolve(AnonCredsCredentialDefinitionPrivateRepository).save( + agentContext, + new AnonCredsCredentialDefinitionPrivateRecord({ + value: credentialDefinitionPrivate, + credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, + }) + ) + + await agentContext.dependencyManager.resolve(AnonCredsKeyCorrectnessProofRepository).save( + agentContext, + new AnonCredsKeyCorrectnessProofRecord({ + value: keyCorrectnessProof, + credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, }) + ) - const { credentialDefinition, credentialDefinitionPrivate, keyCorrectnessProof } = - await anonCredsIssuerService.createCredentialDefinition(agentContext, { + let revocationRegistryDefinitionId: string | undefined + if (revocable) { + const { revocationRegistryDefinition, revocationRegistryDefinitionPrivate } = + await anonCredsIssuerService.createRevocationRegistryDefinition(agentContext, { issuerId: indyDid, - schemaId: schemaState.schemaId as string, - schema, - tag: 'Employee Credential', - supportRevocation: false, + credentialDefinition, + credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, + maximumCredentialNumber: 100, + tailsDirectoryPath: await tailsFileService.getTailsBasePath(agentContext), + tag: 'default', }) - const { credentialDefinitionState } = await registry.registerCredentialDefinition(agentContext, { - credentialDefinition, + // At this moment, tails file should be published and a valid public URL will be received + const localTailsFilePath = revocationRegistryDefinition.value.tailsLocation + + const { revocationRegistryDefinitionState } = await registry.registerRevocationRegistryDefinition(agentContext, { + revocationRegistryDefinition, options: {}, }) - if ( - !credentialDefinitionState.credentialDefinition || - !credentialDefinitionState.credentialDefinitionId || - !schemaState.schema || - !schemaState.schemaId - ) { - throw new Error('Failed to create schema or credential definition') - } + revocationRegistryDefinitionId = revocationRegistryDefinitionState.revocationRegistryDefinitionId if ( - !credentialDefinitionState.credentialDefinition || - !credentialDefinitionState.credentialDefinitionId || - !schemaState.schema || - !schemaState.schemaId + !revocationRegistryDefinitionState.revocationRegistryDefinition || + !revocationRegistryDefinitionId || + !revocationRegistryDefinitionPrivate ) { - throw new Error('Failed to create schema or credential definition') - } - - if (!credentialDefinitionPrivate || !keyCorrectnessProof) { - throw new Error('Failed to get private part of credential definition') + throw new Error('Failed to create revocation registry') } - await agentContext.dependencyManager.resolve(AnonCredsSchemaRepository).save( + await agentContext.dependencyManager.resolve(AnonCredsRevocationRegistryDefinitionRepository).save( agentContext, - new AnonCredsSchemaRecord({ - schema: schemaState.schema, - schemaId: schemaState.schemaId, - methodName: 'inMemory', + new AnonCredsRevocationRegistryDefinitionRecord({ + revocationRegistryDefinition: revocationRegistryDefinitionState.revocationRegistryDefinition, + revocationRegistryDefinitionId, }) ) - await agentContext.dependencyManager.resolve(AnonCredsCredentialDefinitionRepository).save( + await agentContext.dependencyManager.resolve(AnonCredsRevocationRegistryDefinitionPrivateRepository).save( agentContext, - new AnonCredsCredentialDefinitionRecord({ - credentialDefinition: credentialDefinitionState.credentialDefinition, - credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, - methodName: 'inMemory', + new AnonCredsRevocationRegistryDefinitionPrivateRecord({ + state: AnonCredsRevocationRegistryState.Active, + value: revocationRegistryDefinitionPrivate, + credentialDefinitionId: revocationRegistryDefinitionState.revocationRegistryDefinition.credDefId, + revocationRegistryDefinitionId, }) ) - await agentContext.dependencyManager.resolve(AnonCredsCredentialDefinitionPrivateRepository).save( - agentContext, - new AnonCredsCredentialDefinitionPrivateRecord({ - value: credentialDefinitionPrivate, - credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, - }) - ) + const createdRevocationStatusList = await anonCredsIssuerService.createRevocationStatusList(agentContext, { + issuerId: indyDid, + revocationRegistryDefinition, + revocationRegistryDefinitionId, + tailsFilePath: localTailsFilePath, + }) - await agentContext.dependencyManager.resolve(AnonCredsKeyCorrectnessProofRepository).save( - agentContext, - new AnonCredsKeyCorrectnessProofRecord({ - value: keyCorrectnessProof, - credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, - }) - ) + const { revocationStatusListState } = await registry.registerRevocationStatusList(agentContext, { + revocationStatusList: createdRevocationStatusList, + options: {}, + }) - const linkSecret = await anonCredsHolderService.createLinkSecret(agentContext, { linkSecretId: 'linkSecretId' }) - expect(linkSecret.linkSecretId).toBe('linkSecretId') + if (!revocationStatusListState.revocationStatusList || !revocationStatusListState.timestamp) { + throw new Error('Failed to create revocation status list') + } + } - await agentContext.dependencyManager.resolve(AnonCredsLinkSecretRepository).save( - agentContext, - new AnonCredsLinkSecretRecord({ - value: linkSecret.linkSecretValue, - linkSecretId: linkSecret.linkSecretId, - }) - ) + const linkSecret = await anonCredsHolderService.createLinkSecret(agentContext, { linkSecretId: 'linkSecretId' }) + expect(linkSecret.linkSecretId).toBe('linkSecretId') - const holderCredentialRecord = new CredentialExchangeRecord({ - protocolVersion: 'v1', - state: CredentialState.ProposalSent, - threadId: 'f365c1a5-2baf-4873-9432-fa87c888a0aa', + await agentContext.dependencyManager.resolve(AnonCredsLinkSecretRepository).save( + agentContext, + new AnonCredsLinkSecretRecord({ + value: linkSecret.linkSecretValue, + linkSecretId: linkSecret.linkSecretId, }) + ) - const issuerCredentialRecord = new CredentialExchangeRecord({ - protocolVersion: 'v1', - state: CredentialState.ProposalReceived, - threadId: 'f365c1a5-2baf-4873-9432-fa87c888a0aa', - }) + const holderCredentialRecord = new CredentialExchangeRecord({ + protocolVersion: 'v1', + state: CredentialState.ProposalSent, + threadId: 'f365c1a5-2baf-4873-9432-fa87c888a0aa', + }) + + const issuerCredentialRecord = new CredentialExchangeRecord({ + protocolVersion: 'v1', + state: CredentialState.ProposalReceived, + threadId: 'f365c1a5-2baf-4873-9432-fa87c888a0aa', + }) - const credentialAttributes = [ - new CredentialPreviewAttribute({ - name: 'name', - value: 'John', - }), - new CredentialPreviewAttribute({ - name: 'age', - value: '25', - }), - ] - - // Holder creates proposal - holderCredentialRecord.credentialAttributes = credentialAttributes - const { attachment: proposalAttachment } = await anoncredsCredentialFormatService.createProposal(agentContext, { - credentialRecord: holderCredentialRecord, - credentialFormats: { - anoncreds: { - attributes: credentialAttributes, - credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, - }, + const credentialAttributes = [ + new CredentialPreviewAttribute({ + name: 'name', + value: 'John', + }), + new CredentialPreviewAttribute({ + name: 'age', + value: '25', + }), + ] + + // Holder creates proposal + holderCredentialRecord.credentialAttributes = credentialAttributes + const { attachment: proposalAttachment } = await anoncredsCredentialFormatService.createProposal(agentContext, { + credentialRecord: holderCredentialRecord, + credentialFormats: { + anoncreds: { + attributes: credentialAttributes, + credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, }, - }) + }, + }) - // Issuer processes and accepts proposal - await anoncredsCredentialFormatService.processProposal(agentContext, { - credentialRecord: issuerCredentialRecord, - attachment: proposalAttachment, - }) - // Set attributes on the credential record, this is normally done by the protocol service - issuerCredentialRecord.credentialAttributes = credentialAttributes - const { attachment: offerAttachment } = await anoncredsCredentialFormatService.acceptProposal(agentContext, { - credentialRecord: issuerCredentialRecord, - proposalAttachment: proposalAttachment, - }) + // Issuer processes and accepts proposal + await anoncredsCredentialFormatService.processProposal(agentContext, { + credentialRecord: issuerCredentialRecord, + attachment: proposalAttachment, + }) + // Set attributes on the credential record, this is normally done by the protocol service + issuerCredentialRecord.credentialAttributes = credentialAttributes + + // If revocable, specify revocation registry definition id and index + const credentialFormats = revocable + ? { anoncreds: { revocationRegistryDefinitionId, revocationRegistryIndex: 1 } } + : undefined + + const { attachment: offerAttachment } = await anoncredsCredentialFormatService.acceptProposal(agentContext, { + credentialRecord: issuerCredentialRecord, + proposalAttachment: proposalAttachment, + credentialFormats, + }) - // Holder processes and accepts offer - await anoncredsCredentialFormatService.processOffer(agentContext, { - credentialRecord: holderCredentialRecord, - attachment: offerAttachment, - }) - const { attachment: requestAttachment } = await anoncredsCredentialFormatService.acceptOffer(agentContext, { - credentialRecord: holderCredentialRecord, - offerAttachment, - credentialFormats: { - anoncreds: { - linkSecretId: linkSecret.linkSecretId, - }, + // Holder processes and accepts offer + await anoncredsCredentialFormatService.processOffer(agentContext, { + credentialRecord: holderCredentialRecord, + attachment: offerAttachment, + }) + const { attachment: requestAttachment } = await anoncredsCredentialFormatService.acceptOffer(agentContext, { + credentialRecord: holderCredentialRecord, + offerAttachment, + credentialFormats: { + anoncreds: { + linkSecretId: linkSecret.linkSecretId, }, - }) + }, + }) - // Make sure the request contains an entropy and does not contain a prover_did field - expect((requestAttachment.getDataAsJson() as AnonCredsCredentialRequest).entropy).toBeDefined() - expect((requestAttachment.getDataAsJson() as AnonCredsCredentialRequest).prover_did).toBeUndefined() + // Make sure the request contains an entropy and does not contain a prover_did field + expect((requestAttachment.getDataAsJson() as AnonCredsCredentialRequest).entropy).toBeDefined() + expect((requestAttachment.getDataAsJson() as AnonCredsCredentialRequest).prover_did).toBeUndefined() - // Issuer processes and accepts request - await anoncredsCredentialFormatService.processRequest(agentContext, { - credentialRecord: issuerCredentialRecord, - attachment: requestAttachment, - }) - const { attachment: credentialAttachment } = await anoncredsCredentialFormatService.acceptRequest(agentContext, { - credentialRecord: issuerCredentialRecord, - requestAttachment, - offerAttachment, - }) + // Issuer processes and accepts request + await anoncredsCredentialFormatService.processRequest(agentContext, { + credentialRecord: issuerCredentialRecord, + attachment: requestAttachment, + }) + const { attachment: credentialAttachment } = await anoncredsCredentialFormatService.acceptRequest(agentContext, { + credentialRecord: issuerCredentialRecord, + requestAttachment, + offerAttachment, + }) - // Holder processes and accepts credential - await anoncredsCredentialFormatService.processCredential(agentContext, { - credentialRecord: holderCredentialRecord, - attachment: credentialAttachment, - requestAttachment, - }) + // Holder processes and accepts credential + await anoncredsCredentialFormatService.processCredential(agentContext, { + credentialRecord: holderCredentialRecord, + attachment: credentialAttachment, + requestAttachment, + }) - expect(holderCredentialRecord.credentials).toEqual([ - { credentialRecordType: 'anoncreds', credentialRecordId: expect.any(String) }, - ]) + expect(holderCredentialRecord.credentials).toEqual([ + { credentialRecordType: 'anoncreds', credentialRecordId: expect.any(String) }, + ]) - const credentialId = holderCredentialRecord.credentials[0].credentialRecordId - const anonCredsCredential = await anonCredsHolderService.getCredential(agentContext, { - credentialId, - }) + const credentialId = holderCredentialRecord.credentials[0].credentialRecordId + const anonCredsCredential = await anonCredsHolderService.getCredential(agentContext, { + credentialId, + }) - expect(anonCredsCredential).toEqual({ - credentialId, - attributes: { - age: '25', - name: 'John', - }, - schemaId: schemaState.schemaId, - credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, - revocationRegistryId: null, - credentialRevocationId: undefined, // FIXME: should be null? - methodName: 'inMemory', - }) + expect(anonCredsCredential).toEqual({ + credentialId, + attributes: { + age: '25', + name: 'John', + }, + schemaId: schemaState.schemaId, + credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, + revocationRegistryId: revocable ? revocationRegistryDefinitionId : null, + credentialRevocationId: revocable ? '1' : undefined, + methodName: 'inMemory', + }) - expect(holderCredentialRecord.metadata.data).toEqual({ - '_anoncreds/credential': { + const expectedCredentialMetadata = revocable + ? { schemaId: schemaState.schemaId, credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, - }, - '_anoncreds/credentialRequest': { - link_secret_blinding_data: expect.any(Object), - link_secret_name: expect.any(String), - nonce: expect.any(String), - }, - }) - - expect(issuerCredentialRecord.metadata.data).toEqual({ - '_anoncreds/credential': { + revocationRegistryId: revocationRegistryDefinitionId, + credentialRevocationId: '1', + } + : { schemaId: schemaState.schemaId, credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, - }, - }) + } + expect(holderCredentialRecord.metadata.data).toEqual({ + '_anoncreds/credential': expectedCredentialMetadata, + '_anoncreds/credentialRequest': { + link_secret_blinding_data: expect.any(Object), + link_secret_name: expect.any(String), + nonce: expect.any(String), + }, + }) - const holderProofRecord = new ProofExchangeRecord({ - protocolVersion: 'v1', - state: ProofState.ProposalSent, - threadId: '4f5659a4-1aea-4f42-8c22-9a9985b35e38', - }) - const verifierProofRecord = new ProofExchangeRecord({ - protocolVersion: 'v1', - state: ProofState.ProposalReceived, - threadId: '4f5659a4-1aea-4f42-8c22-9a9985b35e38', - }) + expect(issuerCredentialRecord.metadata.data).toEqual({ + '_anoncreds/credential': expectedCredentialMetadata, + }) - const { attachment: proofProposalAttachment } = await anoncredsProofFormatService.createProposal(agentContext, { - proofFormats: { - anoncreds: { - attributes: [ - { - name: 'name', - credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, - value: 'John', - referent: '1', - }, - ], - predicates: [ - { - credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, - name: 'age', - predicate: '>=', - threshold: 18, - }, - ], - name: 'Proof Request', - version: '1.0', - }, - }, - proofRecord: holderProofRecord, - }) + const holderProofRecord = new ProofExchangeRecord({ + protocolVersion: 'v1', + state: ProofState.ProposalSent, + threadId: '4f5659a4-1aea-4f42-8c22-9a9985b35e38', + }) + const verifierProofRecord = new ProofExchangeRecord({ + protocolVersion: 'v1', + state: ProofState.ProposalReceived, + threadId: '4f5659a4-1aea-4f42-8c22-9a9985b35e38', + }) - await anoncredsProofFormatService.processProposal(agentContext, { - attachment: proofProposalAttachment, - proofRecord: verifierProofRecord, - }) + const nrpRequestedTime = dateToTimestamp(new Date()) + + const { attachment: proofProposalAttachment } = await anoncredsProofFormatService.createProposal(agentContext, { + proofFormats: { + anoncreds: { + attributes: [ + { + name: 'name', + credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, + value: 'John', + referent: '1', + }, + ], + predicates: [ + { + credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, + name: 'age', + predicate: '>=', + threshold: 18, + }, + ], + name: 'Proof Request', + version: '1.0', + nonRevokedInterval: { from: nrpRequestedTime, to: nrpRequestedTime }, + }, + }, + proofRecord: holderProofRecord, + }) - const { attachment: proofRequestAttachment } = await anoncredsProofFormatService.acceptProposal(agentContext, { - proofRecord: verifierProofRecord, - proposalAttachment: proofProposalAttachment, - }) + await anoncredsProofFormatService.processProposal(agentContext, { + attachment: proofProposalAttachment, + proofRecord: verifierProofRecord, + }) - await anoncredsProofFormatService.processRequest(agentContext, { - attachment: proofRequestAttachment, - proofRecord: holderProofRecord, - }) + const { attachment: proofRequestAttachment } = await anoncredsProofFormatService.acceptProposal(agentContext, { + proofRecord: verifierProofRecord, + proposalAttachment: proofProposalAttachment, + }) - const { attachment: proofAttachment } = await anoncredsProofFormatService.acceptRequest(agentContext, { - proofRecord: holderProofRecord, - requestAttachment: proofRequestAttachment, - proposalAttachment: proofProposalAttachment, - }) + await anoncredsProofFormatService.processRequest(agentContext, { + attachment: proofRequestAttachment, + proofRecord: holderProofRecord, + }) - const isValid = await anoncredsProofFormatService.processPresentation(agentContext, { - attachment: proofAttachment, - proofRecord: verifierProofRecord, - requestAttachment: proofRequestAttachment, - }) + const { attachment: proofAttachment } = await anoncredsProofFormatService.acceptRequest(agentContext, { + proofRecord: holderProofRecord, + requestAttachment: proofRequestAttachment, + proposalAttachment: proofProposalAttachment, + }) - expect(isValid).toBe(true) + const isValid = await anoncredsProofFormatService.processPresentation(agentContext, { + attachment: proofAttachment, + proofRecord: verifierProofRecord, + requestAttachment: proofRequestAttachment, }) -}) + + expect(isValid).toBe(true) +} diff --git a/packages/anoncreds-rs/tests/anoncredsSetup.ts b/packages/anoncreds-rs/tests/anoncredsSetup.ts new file mode 100644 index 0000000000..5f3cbd85ab --- /dev/null +++ b/packages/anoncreds-rs/tests/anoncredsSetup.ts @@ -0,0 +1,561 @@ +import type { + AnonCredsRegisterCredentialDefinitionOptions, + AnonCredsRequestedAttribute, + AnonCredsRequestedPredicate, + AnonCredsOfferCredentialFormat, + AnonCredsSchema, + RegisterCredentialDefinitionReturnStateFinished, + RegisterSchemaReturnStateFinished, + AnonCredsRegistry, + AnonCredsRegisterRevocationRegistryDefinitionOptions, + RegisterRevocationRegistryDefinitionReturnStateFinished, + AnonCredsRegisterRevocationStatusListOptions, + RegisterRevocationStatusListReturnStateFinished, +} from '../../anoncreds/src' +import type { EventReplaySubject } from '../../core/tests' +import type { AutoAcceptProof, ConnectionRecord } from '@aries-framework/core' + +import { + DidDocumentBuilder, + CacheModule, + InMemoryLruCache, + Agent, + AriesFrameworkError, + AutoAcceptCredential, + CredentialEventTypes, + CredentialsModule, + CredentialState, + ProofEventTypes, + ProofsModule, + ProofState, + V2CredentialProtocol, + V2ProofProtocol, + DidsModule, +} from '@aries-framework/core' +import { anoncreds } from '@hyperledger/anoncreds-nodejs' +import { randomUUID } from 'crypto' + +import { AnonCredsCredentialFormatService, AnonCredsProofFormatService, AnonCredsModule } from '../../anoncreds/src' +import { InMemoryAnonCredsRegistry } from '../../anoncreds/tests/InMemoryAnonCredsRegistry' +import { AskarModule } from '../../askar/src' +import { askarModuleConfig } from '../../askar/tests/helpers' +import { sleep } from '../../core/src/utils/sleep' +import { setupSubjectTransports, setupEventReplaySubjects } from '../../core/tests' +import { + getAgentOptions, + makeConnection, + waitForCredentialRecordSubject, + waitForProofExchangeRecordSubject, +} from '../../core/tests/helpers' +import testLogger from '../../core/tests/logger' +import { AnonCredsRsModule } from '../src' + +import { InMemoryTailsFileService } from './InMemoryTailsFileService' +import { LocalDidResolver } from './LocalDidResolver' + +// Helper type to get the type of the agents (with the custom modules) for the credential tests +export type AnonCredsTestsAgent = Agent< + ReturnType & { mediationRecipient?: any; mediator?: any } +> + +export const getAnonCredsModules = ({ + autoAcceptCredentials, + autoAcceptProofs, + registries, +}: { + autoAcceptCredentials?: AutoAcceptCredential + autoAcceptProofs?: AutoAcceptProof + registries?: [AnonCredsRegistry, ...AnonCredsRegistry[]] +} = {}) => { + const anonCredsCredentialFormatService = new AnonCredsCredentialFormatService() + const anonCredsProofFormatService = new AnonCredsProofFormatService() + + const modules = { + credentials: new CredentialsModule({ + autoAcceptCredentials, + credentialProtocols: [ + new V2CredentialProtocol({ + credentialFormats: [anonCredsCredentialFormatService], + }), + ], + }), + proofs: new ProofsModule({ + autoAcceptProofs, + proofProtocols: [ + new V2ProofProtocol({ + proofFormats: [anonCredsProofFormatService], + }), + ], + }), + anoncreds: new AnonCredsModule({ + registries: registries ?? [new InMemoryAnonCredsRegistry()], + tailsFileService: new InMemoryTailsFileService(), + }), + anoncredsRs: new AnonCredsRsModule({ + anoncreds, + }), + dids: new DidsModule({ + resolvers: [new LocalDidResolver()], + }), + askar: new AskarModule(askarModuleConfig), + cache: new CacheModule({ + cache: new InMemoryLruCache({ limit: 100 }), + }), + } as const + + return modules +} + +export async function presentAnonCredsProof({ + verifierAgent, + verifierReplay, + + holderAgent, + holderReplay, + + verifierHolderConnectionId, + + request: { attributes, predicates }, +}: { + holderAgent: AnonCredsTestsAgent + holderReplay: EventReplaySubject + + verifierAgent: AnonCredsTestsAgent + verifierReplay: EventReplaySubject + + verifierHolderConnectionId: string + request: { + attributes?: Record + predicates?: Record + } +}) { + let holderProofExchangeRecordPromise = waitForProofExchangeRecordSubject(holderReplay, { + state: ProofState.RequestReceived, + }) + + let verifierProofExchangeRecord = await verifierAgent.proofs.requestProof({ + connectionId: verifierHolderConnectionId, + proofFormats: { + anoncreds: { + name: 'Test Proof Request', + requested_attributes: attributes, + requested_predicates: predicates, + version: '1.0', + }, + }, + protocolVersion: 'v2', + }) + + let holderProofExchangeRecord = await holderProofExchangeRecordPromise + + const selectedCredentials = await holderAgent.proofs.selectCredentialsForRequest({ + proofRecordId: holderProofExchangeRecord.id, + }) + + const verifierProofExchangeRecordPromise = waitForProofExchangeRecordSubject(verifierReplay, { + threadId: holderProofExchangeRecord.threadId, + state: ProofState.PresentationReceived, + }) + + await holderAgent.proofs.acceptRequest({ + proofRecordId: holderProofExchangeRecord.id, + proofFormats: { anoncreds: selectedCredentials.proofFormats.anoncreds }, + }) + + verifierProofExchangeRecord = await verifierProofExchangeRecordPromise + + // assert presentation is valid + expect(verifierProofExchangeRecord.isVerified).toBe(true) + + holderProofExchangeRecordPromise = waitForProofExchangeRecordSubject(holderReplay, { + threadId: holderProofExchangeRecord.threadId, + state: ProofState.Done, + }) + + verifierProofExchangeRecord = await verifierAgent.proofs.acceptPresentation({ + proofRecordId: verifierProofExchangeRecord.id, + }) + holderProofExchangeRecord = await holderProofExchangeRecordPromise + + return { + verifierProofExchangeRecord, + holderProofExchangeRecord, + } +} + +export async function issueAnonCredsCredential({ + issuerAgent, + issuerReplay, + + holderAgent, + holderReplay, + + issuerHolderConnectionId, + revocationRegistryDefinitionId, + offer, +}: { + issuerAgent: AnonCredsTestsAgent + issuerReplay: EventReplaySubject + + holderAgent: AnonCredsTestsAgent + holderReplay: EventReplaySubject + + issuerHolderConnectionId: string + revocationRegistryDefinitionId?: string + offer: AnonCredsOfferCredentialFormat +}) { + let issuerCredentialExchangeRecord = await issuerAgent.credentials.offerCredential({ + comment: 'some comment about credential', + connectionId: issuerHolderConnectionId, + protocolVersion: 'v2', + credentialFormats: { + anoncreds: { ...offer, revocationRegistryDefinitionId, revocationRegistryIndex: 1 }, + }, + autoAcceptCredential: AutoAcceptCredential.ContentApproved, + }) + + let holderCredentialExchangeRecord = await waitForCredentialRecordSubject(holderReplay, { + threadId: issuerCredentialExchangeRecord.threadId, + state: CredentialState.OfferReceived, + }) + + await holderAgent.credentials.acceptOffer({ + credentialRecordId: holderCredentialExchangeRecord.id, + autoAcceptCredential: AutoAcceptCredential.ContentApproved, + }) + + // Because we use auto-accept it can take a while to have the whole credential flow finished + // Both parties need to interact with the ledger and sign/verify the credential + holderCredentialExchangeRecord = await waitForCredentialRecordSubject(holderReplay, { + threadId: issuerCredentialExchangeRecord.threadId, + state: CredentialState.Done, + }) + issuerCredentialExchangeRecord = await waitForCredentialRecordSubject(issuerReplay, { + threadId: issuerCredentialExchangeRecord.threadId, + state: CredentialState.Done, + }) + + return { + issuerCredentialExchangeRecord, + holderCredentialExchangeRecord, + } +} + +interface SetupAnonCredsTestsReturn { + issuerAgent: AnonCredsTestsAgent + issuerReplay: EventReplaySubject + + holderAgent: AnonCredsTestsAgent + holderReplay: EventReplaySubject + + issuerHolderConnectionId: CreateConnections extends true ? string : undefined + holderIssuerConnectionId: CreateConnections extends true ? string : undefined + + verifierHolderConnectionId: CreateConnections extends true + ? VerifierName extends string + ? string + : undefined + : undefined + holderVerifierConnectionId: CreateConnections extends true + ? VerifierName extends string + ? string + : undefined + : undefined + + verifierAgent: VerifierName extends string ? AnonCredsTestsAgent : undefined + verifierReplay: VerifierName extends string ? EventReplaySubject : undefined + + schemaId: string + credentialDefinitionId: string + revocationRegistryDefinitionId?: string + revocationStatusListTimestamp?: number +} + +export async function setupAnonCredsTests< + VerifierName extends string | undefined = undefined, + CreateConnections extends boolean = true +>({ + issuerId, + issuerName, + holderName, + verifierName, + autoAcceptCredentials, + autoAcceptProofs, + attributeNames, + createConnections, + supportRevocation, + registries, +}: { + issuerId: string + issuerName: string + holderName: string + verifierName?: VerifierName + autoAcceptCredentials?: AutoAcceptCredential + autoAcceptProofs?: AutoAcceptProof + attributeNames: string[] + createConnections?: CreateConnections + supportRevocation?: boolean + registries?: [AnonCredsRegistry, ...AnonCredsRegistry[]] +}): Promise> { + const issuerAgent = new Agent( + getAgentOptions( + issuerName, + { + endpoints: ['rxjs:issuer'], + }, + getAnonCredsModules({ + autoAcceptCredentials, + autoAcceptProofs, + registries, + }) + ) + ) + + const holderAgent = new Agent( + getAgentOptions( + holderName, + { + endpoints: ['rxjs:holder'], + }, + getAnonCredsModules({ + autoAcceptCredentials, + autoAcceptProofs, + registries, + }) + ) + ) + + const verifierAgent = verifierName + ? new Agent( + getAgentOptions( + verifierName, + { + endpoints: ['rxjs:verifier'], + }, + getAnonCredsModules({ + autoAcceptCredentials, + autoAcceptProofs, + registries, + }) + ) + ) + : undefined + + setupSubjectTransports(verifierAgent ? [issuerAgent, holderAgent, verifierAgent] : [issuerAgent, holderAgent]) + const [issuerReplay, holderReplay, verifierReplay] = setupEventReplaySubjects( + verifierAgent ? [issuerAgent, holderAgent, verifierAgent] : [issuerAgent, holderAgent], + [CredentialEventTypes.CredentialStateChanged, ProofEventTypes.ProofStateChanged] + ) + + await issuerAgent.initialize() + await holderAgent.initialize() + if (verifierAgent) await verifierAgent.initialize() + + // Create default link secret for holder + await holderAgent.modules.anoncreds.createLinkSecret({ + linkSecretId: 'default', + setAsDefault: true, + }) + + const { credentialDefinition, revocationRegistryDefinition, revocationStatusList, schema } = + await prepareForAnonCredsIssuance(issuerAgent, { + issuerId, + attributeNames, + supportRevocation, + }) + + let issuerHolderConnection: ConnectionRecord | undefined + let holderIssuerConnection: ConnectionRecord | undefined + let verifierHolderConnection: ConnectionRecord | undefined + let holderVerifierConnection: ConnectionRecord | undefined + + if (createConnections ?? true) { + ;[issuerHolderConnection, holderIssuerConnection] = await makeConnection(issuerAgent, holderAgent) + + if (verifierAgent) { + ;[holderVerifierConnection, verifierHolderConnection] = await makeConnection(holderAgent, verifierAgent) + } + } + + return { + issuerAgent, + issuerReplay, + + holderAgent, + holderReplay, + + verifierAgent: verifierName ? verifierAgent : undefined, + verifierReplay: verifierName ? verifierReplay : undefined, + + revocationRegistryDefinitionId: revocationRegistryDefinition?.revocationRegistryDefinitionId, + revocationStatusListTimestamp: revocationStatusList.revocationStatusList?.timestamp, + credentialDefinitionId: credentialDefinition.credentialDefinitionId, + schemaId: schema.schemaId, + + issuerHolderConnectionId: issuerHolderConnection?.id, + holderIssuerConnectionId: holderIssuerConnection?.id, + holderVerifierConnectionId: holderVerifierConnection?.id, + verifierHolderConnectionId: verifierHolderConnection?.id, + } as unknown as SetupAnonCredsTestsReturn +} + +export async function prepareForAnonCredsIssuance( + agent: Agent, + { + attributeNames, + supportRevocation, + issuerId, + }: { attributeNames: string[]; supportRevocation?: boolean; issuerId: string } +) { + //const key = await agent.wallet.createKey({ keyType: KeyType.Ed25519 }) + + const didDocument = new DidDocumentBuilder(issuerId).build() + + await agent.dids.import({ did: issuerId, didDocument }) + + const schema = await registerSchema(agent, { + // TODO: update attrNames to attributeNames + attrNames: attributeNames, + name: `Schema ${randomUUID()}`, + version: '1.0', + issuerId, + }) + + // Wait some time pass to let ledger settle the object + await sleep(1000) + + const credentialDefinition = await registerCredentialDefinition( + agent, + { + schemaId: schema.schemaId, + issuerId, + tag: 'default', + }, + supportRevocation + ) + + // Wait some time pass to let ledger settle the object + await sleep(1000) + + let revocationRegistryDefinition + let revocationStatusList + if (supportRevocation) { + revocationRegistryDefinition = await registerRevocationRegistryDefinition(agent, { + issuerId, + tag: 'default', + credentialDefinitionId: credentialDefinition.credentialDefinitionId, + maximumCredentialNumber: 10, + }) + + // Wait some time pass to let ledger settle the object + await sleep(1000) + + revocationStatusList = await registerRevocationStatusList(agent, { + revocationRegistryDefinitionId: revocationRegistryDefinition?.revocationRegistryDefinitionId, + issuerId, + }) + + // Wait some time pass to let ledger settle the object + await sleep(1000) + } + + return { + schema: { + ...schema, + schemaId: schema.schemaId, + }, + credentialDefinition: { + ...credentialDefinition, + credentialDefinitionId: credentialDefinition.credentialDefinitionId, + }, + revocationRegistryDefinition: { + ...revocationRegistryDefinition, + revocationRegistryDefinitionId: revocationRegistryDefinition?.revocationRegistryDefinitionId, + }, + revocationStatusList: { + ...revocationStatusList, + }, + } +} + +async function registerSchema( + agent: AnonCredsTestsAgent, + schema: AnonCredsSchema +): Promise { + const { schemaState } = await agent.modules.anoncreds.registerSchema({ + schema, + options: {}, + }) + + testLogger.test(`created schema with id ${schemaState.schemaId}`, schema) + + if (schemaState.state !== 'finished') { + throw new AriesFrameworkError( + `Schema not created: ${schemaState.state === 'failed' ? schemaState.reason : 'Not finished'}` + ) + } + + return schemaState +} + +async function registerCredentialDefinition( + agent: AnonCredsTestsAgent, + credentialDefinition: AnonCredsRegisterCredentialDefinitionOptions, + supportRevocation?: boolean +): Promise { + const { credentialDefinitionState } = await agent.modules.anoncreds.registerCredentialDefinition({ + credentialDefinition, + supportRevocation: supportRevocation ?? false, + options: {}, + }) + + if (credentialDefinitionState.state !== 'finished') { + throw new AriesFrameworkError( + `Credential definition not created: ${ + credentialDefinitionState.state === 'failed' ? credentialDefinitionState.reason : 'Not finished' + }` + ) + } + + return credentialDefinitionState +} + +async function registerRevocationRegistryDefinition( + agent: AnonCredsTestsAgent, + revocationRegistryDefinition: AnonCredsRegisterRevocationRegistryDefinitionOptions +): Promise { + const { revocationRegistryDefinitionState } = await agent.modules.anoncreds.registerRevocationRegistryDefinition({ + revocationRegistryDefinition, + options: {}, + }) + + if (revocationRegistryDefinitionState.state !== 'finished') { + throw new AriesFrameworkError( + `Revocation registry definition not created: ${ + revocationRegistryDefinitionState.state === 'failed' ? revocationRegistryDefinitionState.reason : 'Not finished' + }` + ) + } + + return revocationRegistryDefinitionState +} + +async function registerRevocationStatusList( + agent: AnonCredsTestsAgent, + revocationStatusList: AnonCredsRegisterRevocationStatusListOptions +): Promise { + const { revocationStatusListState } = await agent.modules.anoncreds.registerRevocationStatusList({ + revocationStatusList, + options: {}, + }) + + if (revocationStatusListState.state !== 'finished') { + throw new AriesFrameworkError( + `Revocation status list not created: ${ + revocationStatusListState.state === 'failed' ? revocationStatusListState.reason : 'Not finished' + }` + ) + } + + return revocationStatusListState +} diff --git a/packages/anoncreds-rs/tests/v2-credential-revocation.e2e.test.ts b/packages/anoncreds-rs/tests/v2-credential-revocation.e2e.test.ts new file mode 100644 index 0000000000..f3db7e54cf --- /dev/null +++ b/packages/anoncreds-rs/tests/v2-credential-revocation.e2e.test.ts @@ -0,0 +1,237 @@ +import type { AnonCredsTestsAgent } from './anoncredsSetup' +import type { EventReplaySubject } from '../../core/tests' + +import { + DidCommMessageRepository, + JsonTransformer, + CredentialState, + CredentialExchangeRecord, + V2CredentialPreview, + V2OfferCredentialMessage, +} from '@aries-framework/core' + +import { InMemoryAnonCredsRegistry } from '../../anoncreds/tests/InMemoryAnonCredsRegistry' +import { waitForCredentialRecordSubject } from '../../core/tests' +import { waitForRevocationNotification } from '../../core/tests/helpers' +import testLogger from '../../core/tests/logger' + +import { setupAnonCredsTests } from './anoncredsSetup' + +const credentialPreview = V2CredentialPreview.fromRecord({ + name: 'John', + age: '99', + 'x-ray': 'some x-ray', + profile_picture: 'profile picture', +}) + +describe('IC v2 credential revocation', () => { + let faberAgent: AnonCredsTestsAgent + let aliceAgent: AnonCredsTestsAgent + let credentialDefinitionId: string + let revocationRegistryDefinitionId: string | undefined + let aliceConnectionId: string + + let faberReplay: EventReplaySubject + let aliceReplay: EventReplaySubject + + const inMemoryRegistry = new InMemoryAnonCredsRegistry() + + const issuerId = 'did:indy:local:LjgpST2rjsoxYegQDRm7EL' + + beforeAll(async () => { + ;({ + issuerAgent: faberAgent, + issuerReplay: faberReplay, + holderAgent: aliceAgent, + holderReplay: aliceReplay, + credentialDefinitionId, + revocationRegistryDefinitionId, + holderIssuerConnectionId: aliceConnectionId, + } = await setupAnonCredsTests({ + issuerId, + issuerName: 'Faber Agent Credentials v2', + holderName: 'Alice Agent Credentials v2', + attributeNames: ['name', 'age', 'x-ray', 'profile_picture'], + supportRevocation: true, + registries: [inMemoryRegistry], + })) + }) + + afterAll(async () => { + await faberAgent.shutdown() + await faberAgent.wallet.delete() + await aliceAgent.shutdown() + await aliceAgent.wallet.delete() + }) + + test('Alice starts with V2 credential proposal to Faber', async () => { + testLogger.test('Alice sends (v2) credential proposal to Faber') + + const credentialExchangeRecord = await aliceAgent.credentials.proposeCredential({ + connectionId: aliceConnectionId, + protocolVersion: 'v2', + credentialFormats: { + anoncreds: { + attributes: credentialPreview.attributes, + schemaIssuerDid: 'GMm4vMw8LLrLJjp81kRRLp', + schemaName: 'ahoy', + schemaVersion: '1.0', + schemaId: 'q7ATwTYbQDgiigVijUAej:2:test:1.0', + issuerDid: 'GMm4vMw8LLrLJjp81kRRLp', + credentialDefinitionId: 'GMm4vMw8LLrLJjp81kRRLp:3:CL:12:tag', + }, + }, + comment: 'v2 propose credential test', + }) + + expect(credentialExchangeRecord).toMatchObject({ + connectionId: aliceConnectionId, + protocolVersion: 'v2', + state: CredentialState.ProposalSent, + threadId: expect.any(String), + }) + + testLogger.test('Faber waits for credential proposal from Alice') + let faberCredentialRecord = await waitForCredentialRecordSubject(faberReplay, { + threadId: credentialExchangeRecord.threadId, + state: CredentialState.ProposalReceived, + }) + + testLogger.test('Faber sends credential offer to Alice') + await faberAgent.credentials.acceptProposal({ + credentialRecordId: faberCredentialRecord.id, + comment: 'V2 AnonCreds Proposal', + credentialFormats: { + anoncreds: { + credentialDefinitionId: credentialDefinitionId, + attributes: credentialPreview.attributes, + revocationRegistryDefinitionId, + revocationRegistryIndex: 1, + }, + }, + }) + + testLogger.test('Alice waits for credential offer from Faber') + let aliceCredentialRecord = await waitForCredentialRecordSubject(aliceReplay, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.OfferReceived, + }) + + const didCommMessageRepository = faberAgent.dependencyManager.resolve(DidCommMessageRepository) + const offerMessage = await didCommMessageRepository.findAgentMessage(faberAgent.context, { + associatedRecordId: faberCredentialRecord.id, + messageClass: V2OfferCredentialMessage, + }) + + expect(JsonTransformer.toJSON(offerMessage)).toMatchObject({ + '@id': expect.any(String), + '@type': 'https://didcomm.org/issue-credential/2.0/offer-credential', + comment: 'V2 AnonCreds Proposal', + credential_preview: { + '@type': 'https://didcomm.org/issue-credential/2.0/credential-preview', + attributes: [ + { + name: 'name', + 'mime-type': 'text/plain', + value: 'John', + }, + { + name: 'age', + 'mime-type': 'text/plain', + value: '99', + }, + { + name: 'x-ray', + 'mime-type': 'text/plain', + value: 'some x-ray', + }, + { + name: 'profile_picture', + 'mime-type': 'text/plain', + value: 'profile picture', + }, + ], + }, + 'offers~attach': expect.any(Array), + }) + + expect(aliceCredentialRecord).toMatchObject({ + id: expect.any(String), + connectionId: expect.any(String), + type: CredentialExchangeRecord.type, + }) + + // below values are not in json object + expect(aliceCredentialRecord.getTags()).toEqual({ + threadId: faberCredentialRecord.threadId, + connectionId: aliceCredentialRecord.connectionId, + state: aliceCredentialRecord.state, + credentialIds: [], + }) + + const offerCredentialExchangeRecord = await aliceAgent.credentials.acceptOffer({ + credentialRecordId: aliceCredentialRecord.id, + }) + + expect(offerCredentialExchangeRecord).toMatchObject({ + connectionId: aliceConnectionId, + protocolVersion: 'v2', + state: CredentialState.RequestSent, + threadId: expect.any(String), + }) + + testLogger.test('Faber waits for credential request from Alice') + faberCredentialRecord = await waitForCredentialRecordSubject(faberReplay, { + threadId: aliceCredentialRecord.threadId, + state: CredentialState.RequestReceived, + }) + + testLogger.test('Faber sends credential to Alice') + await faberAgent.credentials.acceptRequest({ + credentialRecordId: faberCredentialRecord.id, + comment: 'V2 AnonCreds Credential', + }) + + testLogger.test('Alice waits for credential from Faber') + aliceCredentialRecord = await waitForCredentialRecordSubject(aliceReplay, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.CredentialReceived, + }) + + await aliceAgent.credentials.acceptCredential({ + credentialRecordId: aliceCredentialRecord.id, + }) + + testLogger.test('Faber waits for state done') + const doneCredentialRecord = await waitForCredentialRecordSubject(faberReplay, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.Done, + }) + + // Now revoke the credential + const credentialRevocationRegistryDefinitionId = doneCredentialRecord.getTag( + 'anonCredsRevocationRegistryId' + ) as string + const credentialRevocationIndex = doneCredentialRecord.getTag('anonCredsCredentialRevocationId') as string + + expect(credentialRevocationRegistryDefinitionId).toBeDefined() + expect(credentialRevocationIndex).toBeDefined() + expect(credentialRevocationRegistryDefinitionId).toEqual(revocationRegistryDefinitionId) + + await faberAgent.modules.anoncreds.updateRevocationStatusList({ + revocationRegistryDefinitionId: credentialRevocationRegistryDefinitionId, + revokedCredentialIndexes: [Number(credentialRevocationIndex)], + }) + + await faberAgent.credentials.sendRevocationNotification({ + credentialRecordId: doneCredentialRecord.id, + revocationFormat: 'anoncreds', + revocationId: `${credentialRevocationRegistryDefinitionId}::${credentialRevocationIndex}`, + }) + + testLogger.test('Alice waits for credential revocation notification from Faber') + await waitForRevocationNotification(aliceAgent, { + threadId: faberCredentialRecord.threadId, + }) + }) +}) diff --git a/packages/anoncreds-rs/tests/v2-credentials.e2e.test.ts b/packages/anoncreds-rs/tests/v2-credentials.e2e.test.ts new file mode 100644 index 0000000000..e198a16828 --- /dev/null +++ b/packages/anoncreds-rs/tests/v2-credentials.e2e.test.ts @@ -0,0 +1,674 @@ +import type { AnonCredsTestsAgent } from './anoncredsSetup' +import type { EventReplaySubject } from '../../core/tests' +import type { AnonCredsHolderService, AnonCredsProposeCredentialFormat } from '@aries-framework/anoncreds' + +import { AnonCredsHolderServiceSymbol } from '@aries-framework/anoncreds' +import { + DidCommMessageRepository, + JsonTransformer, + CredentialState, + CredentialExchangeRecord, + V2CredentialPreview, + V2IssueCredentialMessage, + V2OfferCredentialMessage, + V2ProposeCredentialMessage, + V2RequestCredentialMessage, +} from '@aries-framework/core' + +import { InMemoryAnonCredsRegistry } from '../../anoncreds/tests/InMemoryAnonCredsRegistry' +import { waitForCredentialRecord, waitForCredentialRecordSubject } from '../../core/tests' +import testLogger from '../../core/tests/logger' + +import { issueAnonCredsCredential, setupAnonCredsTests } from './anoncredsSetup' + +const credentialPreview = V2CredentialPreview.fromRecord({ + name: 'John', + age: '99', + 'x-ray': 'some x-ray', + profile_picture: 'profile picture', +}) + +describe('IC V2 AnonCreds credentials', () => { + let faberAgent: AnonCredsTestsAgent + let aliceAgent: AnonCredsTestsAgent + let credentialDefinitionId: string + let faberConnectionId: string + let aliceConnectionId: string + + let faberReplay: EventReplaySubject + let aliceReplay: EventReplaySubject + + let anonCredsCredentialProposal: AnonCredsProposeCredentialFormat + + const inMemoryRegistry = new InMemoryAnonCredsRegistry() + + const issuerId = 'did:indy:local:LjgpST2rjsoxYegQDRm7EL' + + const newCredentialPreview = V2CredentialPreview.fromRecord({ + name: 'John', + age: '99', + 'x-ray': 'another x-ray value', + profile_picture: 'another profile picture', + }) + + beforeAll(async () => { + ;({ + issuerAgent: faberAgent, + issuerReplay: faberReplay, + holderAgent: aliceAgent, + holderReplay: aliceReplay, + credentialDefinitionId, + issuerHolderConnectionId: faberConnectionId, + holderIssuerConnectionId: aliceConnectionId, + } = await setupAnonCredsTests({ + issuerId, + issuerName: 'Faber Agent Credentials v2', + holderName: 'Alice Agent Credentials v2', + attributeNames: ['name', 'age', 'x-ray', 'profile_picture'], + registries: [inMemoryRegistry], + })) + + anonCredsCredentialProposal = { + credentialDefinitionId: credentialDefinitionId, + schemaIssuerDid: issuerId, + schemaName: 'ahoy', + schemaVersion: '1.0', + schemaId: `${issuerId}/q7ATwTYbQDgiigVijUAej:2:test:1.0`, + issuerDid: issuerId, + } + }) + + afterAll(async () => { + await faberAgent.shutdown() + await faberAgent.wallet.delete() + await aliceAgent.shutdown() + await aliceAgent.wallet.delete() + }) + + test('Alice starts with V2 credential proposal to Faber', async () => { + testLogger.test('Alice sends (v2) credential proposal to Faber') + + const credentialExchangeRecord = await aliceAgent.credentials.proposeCredential({ + connectionId: aliceConnectionId, + protocolVersion: 'v2', + credentialFormats: { + anoncreds: { + attributes: credentialPreview.attributes, + schemaIssuerDid: issuerId, + schemaName: 'ahoy', + schemaVersion: '1.0', + schemaId: `${issuerId}/q7ATwTYbQDgiigVijUAej:2:test:1.0`, + issuerDid: issuerId, + credentialDefinitionId: `${issuerId}/:3:CL:12:tag`, + }, + }, + comment: 'v2 propose credential test', + }) + + expect(credentialExchangeRecord).toMatchObject({ + connectionId: aliceConnectionId, + protocolVersion: 'v2', + state: CredentialState.ProposalSent, + threadId: expect.any(String), + }) + + testLogger.test('Faber waits for credential proposal from Alice') + let faberCredentialRecord = await waitForCredentialRecordSubject(faberReplay, { + threadId: credentialExchangeRecord.threadId, + state: CredentialState.ProposalReceived, + }) + + testLogger.test('Faber sends credential offer to Alice') + await faberAgent.credentials.acceptProposal({ + credentialRecordId: faberCredentialRecord.id, + comment: 'V2 AnonCreds Proposal', + credentialFormats: { + anoncreds: { + credentialDefinitionId: credentialDefinitionId, + attributes: credentialPreview.attributes, + }, + }, + }) + + testLogger.test('Alice waits for credential offer from Faber') + let aliceCredentialRecord = await waitForCredentialRecordSubject(aliceReplay, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.OfferReceived, + }) + + const didCommMessageRepository = faberAgent.dependencyManager.resolve(DidCommMessageRepository) + const offerMessage = await didCommMessageRepository.findAgentMessage(faberAgent.context, { + associatedRecordId: faberCredentialRecord.id, + messageClass: V2OfferCredentialMessage, + }) + + expect(JsonTransformer.toJSON(offerMessage)).toMatchObject({ + '@id': expect.any(String), + '@type': 'https://didcomm.org/issue-credential/2.0/offer-credential', + comment: 'V2 AnonCreds Proposal', + credential_preview: { + '@type': 'https://didcomm.org/issue-credential/2.0/credential-preview', + attributes: [ + { + name: 'name', + 'mime-type': 'text/plain', + value: 'John', + }, + { + name: 'age', + 'mime-type': 'text/plain', + value: '99', + }, + { + name: 'x-ray', + 'mime-type': 'text/plain', + value: 'some x-ray', + }, + { + name: 'profile_picture', + 'mime-type': 'text/plain', + value: 'profile picture', + }, + ], + }, + 'offers~attach': expect.any(Array), + }) + + expect(aliceCredentialRecord).toMatchObject({ + id: expect.any(String), + connectionId: expect.any(String), + type: CredentialExchangeRecord.type, + }) + + // below values are not in json object + expect(aliceCredentialRecord.getTags()).toEqual({ + threadId: faberCredentialRecord.threadId, + connectionId: aliceCredentialRecord.connectionId, + state: aliceCredentialRecord.state, + credentialIds: [], + }) + + const offerCredentialExchangeRecord = await aliceAgent.credentials.acceptOffer({ + credentialRecordId: aliceCredentialRecord.id, + }) + + expect(offerCredentialExchangeRecord).toMatchObject({ + connectionId: aliceConnectionId, + protocolVersion: 'v2', + state: CredentialState.RequestSent, + threadId: expect.any(String), + }) + + testLogger.test('Faber waits for credential request from Alice') + faberCredentialRecord = await waitForCredentialRecordSubject(faberReplay, { + threadId: aliceCredentialRecord.threadId, + state: CredentialState.RequestReceived, + }) + + testLogger.test('Faber sends credential to Alice') + await faberAgent.credentials.acceptRequest({ + credentialRecordId: faberCredentialRecord.id, + comment: 'V2 AnonCreds Credential', + }) + + testLogger.test('Alice waits for credential from Faber') + aliceCredentialRecord = await waitForCredentialRecordSubject(aliceReplay, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.CredentialReceived, + }) + + await aliceAgent.credentials.acceptCredential({ + credentialRecordId: aliceCredentialRecord.id, + }) + + testLogger.test('Faber waits for state done') + await waitForCredentialRecordSubject(faberReplay, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.Done, + }) + }) + + test('Faber issues credential which is then deleted from Alice`s wallet', async () => { + const { holderCredentialExchangeRecord } = await issueAnonCredsCredential({ + issuerAgent: faberAgent, + issuerReplay: faberReplay, + issuerHolderConnectionId: faberConnectionId, + holderAgent: aliceAgent, + holderReplay: aliceReplay, + offer: { + credentialDefinitionId: credentialDefinitionId, + attributes: credentialPreview.attributes, + }, + }) + + // test that delete credential removes from both repository and wallet + // latter is tested by spying on holder service to + // see if deleteCredential is called + const holderService = aliceAgent.dependencyManager.resolve(AnonCredsHolderServiceSymbol) + + const deleteCredentialSpy = jest.spyOn(holderService, 'deleteCredential') + await aliceAgent.credentials.deleteById(holderCredentialExchangeRecord.id, { + deleteAssociatedCredentials: true, + deleteAssociatedDidCommMessages: true, + }) + expect(deleteCredentialSpy).toHaveBeenNthCalledWith( + 1, + aliceAgent.context, + holderCredentialExchangeRecord.credentials[0].credentialRecordId + ) + + return expect(aliceAgent.credentials.getById(holderCredentialExchangeRecord.id)).rejects.toThrowError( + `CredentialRecord: record with id ${holderCredentialExchangeRecord.id} not found.` + ) + }) + + test('Alice starts with proposal, faber sends a counter offer, alice sends second proposal, faber sends second offer', async () => { + // proposeCredential -> negotiateProposal -> negotiateOffer -> negotiateProposal -> acceptOffer -> acceptRequest -> DONE (credential issued) + + let faberCredentialRecordPromise = waitForCredentialRecord(faberAgent, { + state: CredentialState.ProposalReceived, + }) + + testLogger.test('Alice sends credential proposal to Faber') + let aliceCredentialExchangeRecord = await aliceAgent.credentials.proposeCredential({ + connectionId: aliceConnectionId, + protocolVersion: 'v2', + credentialFormats: { + anoncreds: { + ...anonCredsCredentialProposal, + attributes: credentialPreview.attributes, + }, + }, + comment: 'v2 propose credential test', + }) + expect(aliceCredentialExchangeRecord.state).toBe(CredentialState.ProposalSent) + + testLogger.test('Faber waits for credential proposal from Alice') + let faberCredentialRecord = await faberCredentialRecordPromise + + let aliceCredentialRecordPromise = waitForCredentialRecord(aliceAgent, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.OfferReceived, + }) + + faberCredentialRecord = await faberAgent.credentials.negotiateProposal({ + credentialRecordId: faberCredentialRecord.id, + credentialFormats: { + anoncreds: { + credentialDefinitionId: credentialDefinitionId, + attributes: newCredentialPreview.attributes, + }, + }, + }) + + testLogger.test('Alice waits for credential offer from Faber') + let aliceCredentialRecord = await aliceCredentialRecordPromise + + // Check if the state of the credential records did not change + faberCredentialRecord = await faberAgent.credentials.getById(faberCredentialRecord.id) + faberCredentialRecord.assertState(CredentialState.OfferSent) + + aliceCredentialRecord = await aliceAgent.credentials.getById(aliceCredentialRecord.id) + aliceCredentialRecord.assertState(CredentialState.OfferReceived) + + faberCredentialRecordPromise = waitForCredentialRecord(faberAgent, { + threadId: aliceCredentialExchangeRecord.threadId, + state: CredentialState.ProposalReceived, + }) + + // second proposal + aliceCredentialExchangeRecord = await aliceAgent.credentials.negotiateOffer({ + credentialRecordId: aliceCredentialRecord.id, + credentialFormats: { + anoncreds: { + ...anonCredsCredentialProposal, + attributes: newCredentialPreview.attributes, + }, + }, + }) + + expect(aliceCredentialExchangeRecord.state).toBe(CredentialState.ProposalSent) + + testLogger.test('Faber waits for credential proposal from Alice') + faberCredentialRecord = await faberCredentialRecordPromise + + aliceCredentialRecordPromise = waitForCredentialRecord(aliceAgent, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.OfferReceived, + }) + + faberCredentialRecord = await faberAgent.credentials.negotiateProposal({ + credentialRecordId: faberCredentialRecord.id, + credentialFormats: { + anoncreds: { + credentialDefinitionId: credentialDefinitionId, + attributes: newCredentialPreview.attributes, + }, + }, + }) + + testLogger.test('Alice waits for credential offer from Faber') + + aliceCredentialRecord = await aliceCredentialRecordPromise + + const offerCredentialExchangeRecord = await aliceAgent.credentials.acceptOffer({ + credentialRecordId: aliceCredentialExchangeRecord.id, + }) + + expect(offerCredentialExchangeRecord).toMatchObject({ + connectionId: aliceConnectionId, + state: CredentialState.RequestSent, + protocolVersion: 'v2', + threadId: aliceCredentialExchangeRecord.threadId, + }) + + testLogger.test('Faber waits for credential request from Alice') + faberCredentialRecord = await waitForCredentialRecordSubject(faberReplay, { + threadId: aliceCredentialExchangeRecord.threadId, + state: CredentialState.RequestReceived, + }) + testLogger.test('Faber sends credential to Alice') + + await faberAgent.credentials.acceptRequest({ + credentialRecordId: faberCredentialRecord.id, + comment: 'V2 AnonCreds Credential', + }) + + testLogger.test('Alice waits for credential from Faber') + aliceCredentialRecord = await waitForCredentialRecordSubject(aliceReplay, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.CredentialReceived, + }) + + // testLogger.test('Alice sends credential ack to Faber') + await aliceAgent.credentials.acceptCredential({ credentialRecordId: aliceCredentialRecord.id }) + + testLogger.test('Faber waits for credential ack from Alice') + faberCredentialRecord = await waitForCredentialRecordSubject(faberReplay, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.Done, + }) + expect(aliceCredentialRecord).toMatchObject({ + type: CredentialExchangeRecord.type, + id: expect.any(String), + createdAt: expect.any(Date), + threadId: expect.any(String), + connectionId: expect.any(String), + state: CredentialState.CredentialReceived, + }) + }) + + test('Faber starts with offer, alice sends counter proposal, faber sends second offer, alice sends second proposal', async () => { + let aliceCredentialRecordPromise = waitForCredentialRecord(aliceAgent, { + state: CredentialState.OfferReceived, + }) + + testLogger.test('Faber sends credential offer to Alice') + let faberCredentialRecord = await faberAgent.credentials.offerCredential({ + comment: 'some comment about credential', + connectionId: faberConnectionId, + credentialFormats: { + anoncreds: { + attributes: credentialPreview.attributes, + credentialDefinitionId: credentialDefinitionId, + }, + }, + protocolVersion: 'v2', + }) + + testLogger.test('Alice waits for credential offer from Faber') + let aliceCredentialRecord = await aliceCredentialRecordPromise + + let faberCredentialRecordPromise = waitForCredentialRecord(faberAgent, { + threadId: aliceCredentialRecord.threadId, + state: CredentialState.ProposalReceived, + }) + + aliceCredentialRecord = await aliceAgent.credentials.negotiateOffer({ + credentialRecordId: aliceCredentialRecord.id, + credentialFormats: { + anoncreds: { + ...anonCredsCredentialProposal, + attributes: newCredentialPreview.attributes, + }, + }, + }) + + expect(aliceCredentialRecord.state).toBe(CredentialState.ProposalSent) + + testLogger.test('Faber waits for credential proposal from Alice') + faberCredentialRecord = await faberCredentialRecordPromise + + aliceCredentialRecordPromise = waitForCredentialRecord(aliceAgent, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.OfferReceived, + }) + faberCredentialRecord = await faberAgent.credentials.negotiateProposal({ + credentialRecordId: faberCredentialRecord.id, + credentialFormats: { + anoncreds: { + credentialDefinitionId: credentialDefinitionId, + attributes: newCredentialPreview.attributes, + }, + }, + }) + + testLogger.test('Alice waits for credential offer from Faber') + + aliceCredentialRecord = await aliceCredentialRecordPromise + + faberCredentialRecordPromise = waitForCredentialRecord(faberAgent, { + threadId: aliceCredentialRecord.threadId, + state: CredentialState.ProposalReceived, + }) + + aliceCredentialRecord = await aliceAgent.credentials.negotiateOffer({ + credentialRecordId: aliceCredentialRecord.id, + credentialFormats: { + anoncreds: { + ...anonCredsCredentialProposal, + attributes: newCredentialPreview.attributes, + }, + }, + }) + + expect(aliceCredentialRecord.state).toBe(CredentialState.ProposalSent) + + testLogger.test('Faber waits for credential proposal from Alice') + faberCredentialRecord = await faberCredentialRecordPromise + + aliceCredentialRecordPromise = waitForCredentialRecord(aliceAgent, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.OfferReceived, + }) + + testLogger.test('Faber sends credential offer to Alice') + await faberAgent.credentials.acceptProposal({ + credentialRecordId: faberCredentialRecord.id, + comment: 'V2 AnonCreds Proposal', + credentialFormats: { + anoncreds: { + credentialDefinitionId: credentialDefinitionId, + attributes: credentialPreview.attributes, + }, + }, + }) + + testLogger.test('Alice waits for credential offer from Faber') + aliceCredentialRecord = await aliceCredentialRecordPromise + + faberCredentialRecordPromise = waitForCredentialRecord(faberAgent, { + threadId: aliceCredentialRecord.threadId, + state: CredentialState.RequestReceived, + }) + + const offerCredentialExchangeRecord = await aliceAgent.credentials.acceptOffer({ + credentialRecordId: aliceCredentialRecord.id, + }) + + expect(offerCredentialExchangeRecord).toMatchObject({ + connectionId: aliceConnectionId, + state: CredentialState.RequestSent, + protocolVersion: 'v2', + }) + + testLogger.test('Faber waits for credential request from Alice') + faberCredentialRecord = await faberCredentialRecordPromise + + aliceCredentialRecordPromise = waitForCredentialRecord(aliceAgent, { + threadId: faberCredentialRecord.threadId, + state: CredentialState.CredentialReceived, + }) + + testLogger.test('Faber sends credential to Alice') + await faberAgent.credentials.acceptRequest({ + credentialRecordId: faberCredentialRecord.id, + comment: 'V2 AnonCreds Credential', + }) + + testLogger.test('Alice waits for credential from Faber') + aliceCredentialRecord = await aliceCredentialRecordPromise + + const proposalMessage = await aliceAgent.credentials.findProposalMessage(aliceCredentialRecord.id) + const offerMessage = await aliceAgent.credentials.findOfferMessage(aliceCredentialRecord.id) + const requestMessage = await aliceAgent.credentials.findRequestMessage(aliceCredentialRecord.id) + const credentialMessage = await aliceAgent.credentials.findCredentialMessage(aliceCredentialRecord.id) + + expect(proposalMessage).toBeInstanceOf(V2ProposeCredentialMessage) + expect(offerMessage).toBeInstanceOf(V2OfferCredentialMessage) + expect(requestMessage).toBeInstanceOf(V2RequestCredentialMessage) + expect(credentialMessage).toBeInstanceOf(V2IssueCredentialMessage) + + const formatData = await aliceAgent.credentials.getFormatData(aliceCredentialRecord.id) + expect(formatData).toMatchObject({ + proposalAttributes: [ + { + name: 'name', + mimeType: 'text/plain', + value: 'John', + }, + { + name: 'age', + mimeType: 'text/plain', + value: '99', + }, + { + name: 'x-ray', + mimeType: 'text/plain', + value: 'another x-ray value', + }, + { + name: 'profile_picture', + mimeType: 'text/plain', + value: 'another profile picture', + }, + ], + proposal: { + anoncreds: { + schema_issuer_did: expect.any(String), + schema_id: expect.any(String), + schema_name: expect.any(String), + schema_version: expect.any(String), + cred_def_id: expect.any(String), + issuer_did: expect.any(String), + }, + }, + offer: { + anoncreds: { + schema_id: expect.any(String), + cred_def_id: expect.any(String), + key_correctness_proof: expect.any(Object), + nonce: expect.any(String), + }, + }, + offerAttributes: [ + { + name: 'name', + mimeType: 'text/plain', + value: 'John', + }, + { + name: 'age', + mimeType: 'text/plain', + value: '99', + }, + { + name: 'x-ray', + mimeType: 'text/plain', + value: 'some x-ray', + }, + { + name: 'profile_picture', + mimeType: 'text/plain', + value: 'profile picture', + }, + ], + request: { + anoncreds: { + entropy: expect.any(String), + cred_def_id: expect.any(String), + blinded_ms: expect.any(Object), + blinded_ms_correctness_proof: expect.any(Object), + nonce: expect.any(String), + }, + }, + credential: { + anoncreds: { + schema_id: expect.any(String), + cred_def_id: expect.any(String), + rev_reg_id: null, + values: { + age: { raw: '99', encoded: '99' }, + profile_picture: { + raw: 'profile picture', + encoded: '28661874965215723474150257281172102867522547934697168414362313592277831163345', + }, + name: { + raw: 'John', + encoded: '76355713903561865866741292988746191972523015098789458240077478826513114743258', + }, + 'x-ray': { + raw: 'some x-ray', + encoded: '43715611391396952879378357808399363551139229809726238083934532929974486114650', + }, + }, + signature: expect.any(Object), + signature_correctness_proof: expect.any(Object), + rev_reg: null, + witness: null, + }, + }, + }) + }) + + test('Faber starts with V2 offer, alice declines the offer', async () => { + testLogger.test('Faber sends credential offer to Alice') + const faberCredentialExchangeRecord = await faberAgent.credentials.offerCredential({ + comment: 'some comment about credential', + connectionId: faberConnectionId, + credentialFormats: { + anoncreds: { + attributes: credentialPreview.attributes, + credentialDefinitionId: credentialDefinitionId, + }, + }, + protocolVersion: 'v2', + }) + + testLogger.test('Alice waits for credential offer from Faber') + let aliceCredentialRecord = await waitForCredentialRecordSubject(aliceReplay, { + threadId: faberCredentialExchangeRecord.threadId, + state: CredentialState.OfferReceived, + }) + + expect(aliceCredentialRecord).toMatchObject({ + id: expect.any(String), + type: CredentialExchangeRecord.type, + }) + + testLogger.test('Alice declines offer') + aliceCredentialRecord = await aliceAgent.credentials.declineOffer(aliceCredentialRecord.id) + + expect(aliceCredentialRecord.state).toBe(CredentialState.Declined) + }) +}) diff --git a/packages/anoncreds-rs/tests/v2-proofs.e2e.test.ts b/packages/anoncreds-rs/tests/v2-proofs.e2e.test.ts new file mode 100644 index 0000000000..b304ef5e73 --- /dev/null +++ b/packages/anoncreds-rs/tests/v2-proofs.e2e.test.ts @@ -0,0 +1,1007 @@ +import type { AnonCredsTestsAgent } from './anoncredsSetup' +import type { EventReplaySubject } from '../../core/tests' +import type { AnonCredsRequestProofFormat } from '@aries-framework/anoncreds' +import type { CredentialExchangeRecord } from '@aries-framework/core' + +import { + Attachment, + AttachmentData, + LinkedAttachment, + ProofState, + ProofExchangeRecord, + V2ProposePresentationMessage, + V2RequestPresentationMessage, + V2PresentationMessage, +} from '@aries-framework/core' + +import { dateToTimestamp } from '../../anoncreds/src/utils/timestamp' +import { InMemoryAnonCredsRegistry } from '../../anoncreds/tests/InMemoryAnonCredsRegistry' +import { sleep } from '../../core/src/utils/sleep' +import { waitForProofExchangeRecord } from '../../core/tests' +import testLogger from '../../core/tests/logger' + +import { issueAnonCredsCredential, setupAnonCredsTests } from './anoncredsSetup' + +describe('PP V2 AnonCreds Proofs', () => { + let faberAgent: AnonCredsTestsAgent + let faberReplay: EventReplaySubject + let aliceAgent: AnonCredsTestsAgent + let aliceReplay: EventReplaySubject + let credentialDefinitionId: string + let revocationRegistryDefinitionId: string | undefined + let aliceConnectionId: string + let faberConnectionId: string + let faberProofExchangeRecord: ProofExchangeRecord + let aliceProofExchangeRecord: ProofExchangeRecord + let faberCredentialExchangeRecord: CredentialExchangeRecord + + const inMemoryRegistry = new InMemoryAnonCredsRegistry() + + const issuerId = 'did:indy:local:LjgpST2rjsoxYegQDRm7EL' + + beforeAll(async () => { + testLogger.test('Initializing the agents') + ;({ + issuerAgent: faberAgent, + issuerReplay: faberReplay, + + holderAgent: aliceAgent, + holderReplay: aliceReplay, + credentialDefinitionId, + revocationRegistryDefinitionId, + //revocationStatusListTimestamp, + issuerHolderConnectionId: faberConnectionId, + holderIssuerConnectionId: aliceConnectionId, + } = await setupAnonCredsTests({ + issuerId, + issuerName: 'Faber agent AnonCreds proofs', + holderName: 'Alice agent AnonCreds proofs', + attributeNames: ['name', 'age', 'image_0', 'image_1'], + registries: [inMemoryRegistry], + supportRevocation: true, + })) + ;({ issuerCredentialExchangeRecord: faberCredentialExchangeRecord } = await issueAnonCredsCredential({ + issuerAgent: faberAgent, + holderAgent: aliceAgent, + holderReplay: aliceReplay, + issuerReplay: faberReplay, + issuerHolderConnectionId: faberConnectionId, + revocationRegistryDefinitionId, + offer: { + credentialDefinitionId, + attributes: [ + { + name: 'name', + value: 'John', + }, + { + name: 'age', + value: '99', + }, + ], + linkedAttachments: [ + new LinkedAttachment({ + name: 'image_0', + attachment: new Attachment({ + filename: 'picture-of-a-cat.png', + data: new AttachmentData({ base64: 'cGljdHVyZSBvZiBhIGNhdA==' }), + }), + }), + new LinkedAttachment({ + name: 'image_1', + attachment: new Attachment({ + filename: 'picture-of-a-dog.png', + data: new AttachmentData({ base64: 'UGljdHVyZSBvZiBhIGRvZw==' }), + }), + }), + ], + }, + })) + }) + + afterAll(async () => { + testLogger.test('Shutting down both agents') + await faberAgent.shutdown() + await faberAgent.wallet.delete() + await aliceAgent.shutdown() + await aliceAgent.wallet.delete() + }) + + test('Alice starts with proof proposal to Faber', async () => { + // Alice sends a presentation proposal to Faber + testLogger.test('Alice sends a presentation proposal to Faber') + + let faberProofExchangeRecordPromise = waitForProofExchangeRecord(faberAgent, { + state: ProofState.ProposalReceived, + }) + + aliceProofExchangeRecord = await aliceAgent.proofs.proposeProof({ + connectionId: aliceConnectionId, + protocolVersion: 'v2', + proofFormats: { + anoncreds: { + name: 'abc', + version: '1.0', + attributes: [ + { + name: 'name', + value: 'Alice', + credentialDefinitionId, + }, + ], + predicates: [ + { + name: 'age', + predicate: '>=', + threshold: 50, + credentialDefinitionId, + }, + ], + }, + }, + }) + + // Faber waits for a presentation proposal from Alice + testLogger.test('Faber waits for a presentation proposal from Alice') + faberProofExchangeRecord = await faberProofExchangeRecordPromise + + const proposal = await faberAgent.proofs.findProposalMessage(faberProofExchangeRecord.id) + expect(proposal).toMatchObject({ + type: 'https://didcomm.org/present-proof/2.0/propose-presentation', + formats: [ + { + attachmentId: expect.any(String), + format: 'anoncreds/proof-request@v1.0', + }, + ], + proposalAttachments: [ + { + id: expect.any(String), + mimeType: 'application/json', + data: { + base64: expect.any(String), + }, + }, + ], + id: expect.any(String), + }) + expect(faberProofExchangeRecord.id).not.toBeNull() + expect(faberProofExchangeRecord).toMatchObject({ + threadId: faberProofExchangeRecord.threadId, + state: ProofState.ProposalReceived, + protocolVersion: 'v2', + }) + + let aliceProofExchangeRecordPromise = waitForProofExchangeRecord(aliceAgent, { + state: ProofState.RequestReceived, + }) + + // Faber accepts the presentation proposal from Alice + testLogger.test('Faber accepts presentation proposal from Alice') + faberProofExchangeRecord = await faberAgent.proofs.acceptProposal({ + proofRecordId: faberProofExchangeRecord.id, + }) + + // Alice waits for presentation request from Faber + testLogger.test('Alice waits for presentation request from Faber') + aliceProofExchangeRecord = await aliceProofExchangeRecordPromise + + const request = await faberAgent.proofs.findRequestMessage(faberProofExchangeRecord.id) + expect(request).toMatchObject({ + type: 'https://didcomm.org/present-proof/2.0/request-presentation', + formats: [ + { + attachmentId: expect.any(String), + format: 'anoncreds/proof-request@v1.0', + }, + ], + requestAttachments: [ + { + id: expect.any(String), + mimeType: 'application/json', + data: { + base64: expect.any(String), + }, + }, + ], + id: expect.any(String), + thread: { + threadId: faberProofExchangeRecord.threadId, + }, + }) + + // Alice retrieves the requested credentials and accepts the presentation request + testLogger.test('Alice accepts presentation request from Faber') + + const requestedCredentials = await aliceAgent.proofs.selectCredentialsForRequest({ + proofRecordId: aliceProofExchangeRecord.id, + }) + + faberProofExchangeRecordPromise = waitForProofExchangeRecord(faberAgent, { + threadId: aliceProofExchangeRecord.threadId, + state: ProofState.PresentationReceived, + }) + + await aliceAgent.proofs.acceptRequest({ + proofRecordId: aliceProofExchangeRecord.id, + proofFormats: { anoncreds: requestedCredentials.proofFormats.anoncreds }, + }) + + // Faber waits for the presentation from Alice + testLogger.test('Faber waits for presentation from Alice') + faberProofExchangeRecord = await faberProofExchangeRecordPromise + + const presentation = await faberAgent.proofs.findPresentationMessage(faberProofExchangeRecord.id) + expect(presentation).toMatchObject({ + type: 'https://didcomm.org/present-proof/2.0/presentation', + formats: [ + { + attachmentId: expect.any(String), + format: 'anoncreds/proof@v1.0', + }, + ], + presentationAttachments: [ + { + id: expect.any(String), + mimeType: 'application/json', + data: { + base64: expect.any(String), + }, + }, + ], + id: expect.any(String), + thread: { + threadId: faberProofExchangeRecord.threadId, + }, + }) + expect(faberProofExchangeRecord.id).not.toBeNull() + expect(faberProofExchangeRecord).toMatchObject({ + threadId: faberProofExchangeRecord.threadId, + state: ProofState.PresentationReceived, + protocolVersion: 'v2', + }) + + aliceProofExchangeRecordPromise = waitForProofExchangeRecord(aliceAgent, { + threadId: aliceProofExchangeRecord.threadId, + state: ProofState.Done, + }) + + // Faber accepts the presentation provided by Alice + testLogger.test('Faber accepts the presentation provided by Alice') + await faberAgent.proofs.acceptPresentation({ proofRecordId: faberProofExchangeRecord.id }) + + // Alice waits until she received a presentation acknowledgement + testLogger.test('Alice waits until she receives a presentation acknowledgement') + aliceProofExchangeRecord = await aliceProofExchangeRecordPromise + + expect(faberProofExchangeRecord).toMatchObject({ + type: ProofExchangeRecord.type, + id: expect.any(String), + createdAt: expect.any(Date), + threadId: aliceProofExchangeRecord.threadId, + connectionId: expect.any(String), + isVerified: true, + state: ProofState.PresentationReceived, + }) + + expect(aliceProofExchangeRecord).toMatchObject({ + type: ProofExchangeRecord.type, + id: expect.any(String), + createdAt: expect.any(Date), + threadId: faberProofExchangeRecord.threadId, + connectionId: expect.any(String), + state: ProofState.Done, + }) + + const proposalMessage = await aliceAgent.proofs.findProposalMessage(aliceProofExchangeRecord.id) + const requestMessage = await aliceAgent.proofs.findRequestMessage(aliceProofExchangeRecord.id) + const presentationMessage = await aliceAgent.proofs.findPresentationMessage(aliceProofExchangeRecord.id) + + expect(proposalMessage).toBeInstanceOf(V2ProposePresentationMessage) + expect(requestMessage).toBeInstanceOf(V2RequestPresentationMessage) + expect(presentationMessage).toBeInstanceOf(V2PresentationMessage) + + const formatData = await aliceAgent.proofs.getFormatData(aliceProofExchangeRecord.id) + + expect(formatData).toMatchObject({ + proposal: { + anoncreds: { + name: 'abc', + version: '1.0', + nonce: expect.any(String), + requested_attributes: { + [Object.keys(formatData.proposal?.anoncreds?.requested_attributes ?? {})[0]]: { + name: 'name', + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + }, + requested_predicates: { + [Object.keys(formatData.proposal?.anoncreds?.requested_predicates ?? {})[0]]: { + name: 'age', + p_type: '>=', + p_value: 50, + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + }, + }, + }, + request: { + anoncreds: { + name: 'abc', + version: '1.0', + nonce: expect.any(String), + requested_attributes: { + [Object.keys(formatData.request?.anoncreds?.requested_attributes ?? {})[0]]: { + name: 'name', + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + }, + requested_predicates: { + [Object.keys(formatData.request?.anoncreds?.requested_predicates ?? {})[0]]: { + name: 'age', + p_type: '>=', + p_value: 50, + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + }, + }, + }, + presentation: { + anoncreds: { + proof: { + proofs: [ + { + primary_proof: expect.any(Object), + non_revoc_proof: null, + }, + { + primary_proof: expect.any(Object), + non_revoc_proof: null, + }, + ], + aggregated_proof: { + c_hash: expect.any(String), + c_list: expect.any(Array), + }, + }, + requested_proof: expect.any(Object), + identifiers: expect.any(Array), + }, + }, + }) + }) + + test('Faber starts with proof request to Alice', async () => { + let aliceProofExchangeRecordPromise = waitForProofExchangeRecord(aliceAgent, { + state: ProofState.RequestReceived, + }) + + // Faber sends a presentation request to Alice + testLogger.test('Faber sends a presentation request to Alice') + faberProofExchangeRecord = await faberAgent.proofs.requestProof({ + protocolVersion: 'v2', + connectionId: faberConnectionId, + proofFormats: { + anoncreds: { + name: 'Proof Request', + version: '1.0.0', + requested_attributes: { + name: { + name: 'name', + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + image_0: { + name: 'image_0', + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + }, + requested_predicates: { + age: { + name: 'age', + p_type: '>=', + p_value: 50, + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + }, + }, + }, + }) + + // Alice waits for presentation request from Faber + testLogger.test('Alice waits for presentation request from Faber') + aliceProofExchangeRecord = await aliceProofExchangeRecordPromise + + const request = await faberAgent.proofs.findRequestMessage(faberProofExchangeRecord.id) + expect(request).toMatchObject({ + type: 'https://didcomm.org/present-proof/2.0/request-presentation', + formats: [ + { + attachmentId: expect.any(String), + format: 'anoncreds/proof-request@v1.0', + }, + ], + requestAttachments: [ + { + id: expect.any(String), + mimeType: 'application/json', + data: { + base64: expect.any(String), + }, + }, + ], + id: expect.any(String), + }) + + expect(aliceProofExchangeRecord.id).not.toBeNull() + expect(aliceProofExchangeRecord).toMatchObject({ + threadId: aliceProofExchangeRecord.threadId, + state: ProofState.RequestReceived, + protocolVersion: 'v2', + }) + + // Alice retrieves the requested credentials and accepts the presentation request + testLogger.test('Alice accepts presentation request from Faber') + + const requestedCredentials = await aliceAgent.proofs.selectCredentialsForRequest({ + proofRecordId: aliceProofExchangeRecord.id, + }) + + const faberProofExchangeRecordPromise = waitForProofExchangeRecord(faberAgent, { + threadId: aliceProofExchangeRecord.threadId, + state: ProofState.PresentationReceived, + }) + + await aliceAgent.proofs.acceptRequest({ + proofRecordId: aliceProofExchangeRecord.id, + proofFormats: { anoncreds: requestedCredentials.proofFormats.anoncreds }, + }) + + // Faber waits until it receives a presentation from Alice + testLogger.test('Faber waits for presentation from Alice') + faberProofExchangeRecord = await faberProofExchangeRecordPromise + + const presentation = await faberAgent.proofs.findPresentationMessage(faberProofExchangeRecord.id) + expect(presentation).toMatchObject({ + type: 'https://didcomm.org/present-proof/2.0/presentation', + formats: [ + { + attachmentId: expect.any(String), + format: 'anoncreds/proof@v1.0', + }, + ], + presentationAttachments: [ + { + id: expect.any(String), + mimeType: 'application/json', + data: { + base64: expect.any(String), + }, + }, + ], + id: expect.any(String), + thread: { + threadId: faberProofExchangeRecord.threadId, + }, + }) + expect(faberProofExchangeRecord.id).not.toBeNull() + expect(faberProofExchangeRecord).toMatchObject({ + threadId: faberProofExchangeRecord.threadId, + state: ProofState.PresentationReceived, + protocolVersion: 'v2', + }) + + aliceProofExchangeRecordPromise = waitForProofExchangeRecord(aliceAgent, { + threadId: aliceProofExchangeRecord.threadId, + state: ProofState.Done, + }) + + // Faber accepts the presentation + testLogger.test('Faber accept the presentation from Alice') + await faberAgent.proofs.acceptPresentation({ proofRecordId: faberProofExchangeRecord.id }) + + // Alice waits until she receives a presentation acknowledgement + testLogger.test('Alice waits for acceptance by Faber') + aliceProofExchangeRecord = await aliceProofExchangeRecordPromise + + expect(faberProofExchangeRecord).toMatchObject({ + type: ProofExchangeRecord.type, + id: expect.any(String), + createdAt: expect.any(Date), + threadId: aliceProofExchangeRecord.threadId, + connectionId: expect.any(String), + isVerified: true, + state: ProofState.PresentationReceived, + }) + + expect(aliceProofExchangeRecord).toMatchObject({ + type: ProofExchangeRecord.type, + id: expect.any(String), + createdAt: expect.any(Date), + threadId: faberProofExchangeRecord.threadId, + connectionId: expect.any(String), + state: ProofState.Done, + }) + }) + + test('Alice provides credentials via call to getRequestedCredentials', async () => { + const aliceProofExchangeRecordPromise = waitForProofExchangeRecord(aliceAgent, { + state: ProofState.RequestReceived, + }) + + // Faber sends a presentation request to Alice + testLogger.test('Faber sends a presentation request to Alice') + faberProofExchangeRecord = await faberAgent.proofs.requestProof({ + protocolVersion: 'v2', + connectionId: faberConnectionId, + proofFormats: { + anoncreds: { + name: 'Proof Request', + version: '1.0.0', + requested_attributes: { + name: { + name: 'name', + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + image_0: { + name: 'image_0', + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + }, + requested_predicates: { + age: { + name: 'age', + p_type: '>=', + p_value: 50, + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + }, + }, + }, + }) + + // Alice waits for presentation request from Faber + testLogger.test('Alice waits for presentation request from Faber') + aliceProofExchangeRecord = await aliceProofExchangeRecordPromise + + const retrievedCredentials = await aliceAgent.proofs.getCredentialsForRequest({ + proofRecordId: aliceProofExchangeRecord.id, + }) + + expect(retrievedCredentials).toMatchObject({ + proofFormats: { + anoncreds: { + attributes: { + name: [ + { + credentialId: expect.any(String), + revealed: true, + credentialInfo: { + credentialId: expect.any(String), + attributes: { + image_0: 'hl:zQmfDXo7T3J43j3CTkEZaz7qdHuABhWktksZ7JEBueZ5zUS', + image_1: 'hl:zQmRHBT9rDs5QhsnYuPY3mNpXxgLcnNXkhjWJvTSAPMmcVd', + name: 'John', + age: '99', + }, + schemaId: expect.any(String), + credentialDefinitionId: expect.any(String), + credentialRevocationId: '1', + revocationRegistryId: revocationRegistryDefinitionId, + }, + }, + ], + image_0: [ + { + credentialId: expect.any(String), + revealed: true, + credentialInfo: { + credentialId: expect.any(String), + attributes: { + age: '99', + image_0: 'hl:zQmfDXo7T3J43j3CTkEZaz7qdHuABhWktksZ7JEBueZ5zUS', + image_1: 'hl:zQmRHBT9rDs5QhsnYuPY3mNpXxgLcnNXkhjWJvTSAPMmcVd', + name: 'John', + }, + schemaId: expect.any(String), + credentialDefinitionId: expect.any(String), + credentialRevocationId: '1', + revocationRegistryId: revocationRegistryDefinitionId, + }, + }, + ], + }, + predicates: { + age: [ + { + credentialId: expect.any(String), + credentialInfo: { + credentialId: expect.any(String), + attributes: { + image_1: 'hl:zQmRHBT9rDs5QhsnYuPY3mNpXxgLcnNXkhjWJvTSAPMmcVd', + image_0: 'hl:zQmfDXo7T3J43j3CTkEZaz7qdHuABhWktksZ7JEBueZ5zUS', + name: 'John', + age: '99', + }, + schemaId: expect.any(String), + credentialDefinitionId: expect.any(String), + credentialRevocationId: '1', + revocationRegistryId: revocationRegistryDefinitionId, + }, + }, + ], + }, + }, + }, + }) + }) + + test('Faber starts with proof request to Alice but gets Problem Reported', async () => { + const aliceProofExchangeRecordPromise = waitForProofExchangeRecord(aliceAgent, { + state: ProofState.RequestReceived, + }) + + // Faber sends a presentation request to Alice + testLogger.test('Faber sends a presentation request to Alice') + faberProofExchangeRecord = await faberAgent.proofs.requestProof({ + protocolVersion: 'v2', + connectionId: faberConnectionId, + proofFormats: { + anoncreds: { + name: 'proof-request', + version: '1.0', + requested_attributes: { + name: { + name: 'name', + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + image_0: { + name: 'image_0', + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + }, + requested_predicates: { + age: { + name: 'age', + p_type: '>=', + p_value: 50, + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + }, + }, + }, + }) + + // Alice waits for presentation request from Faber + testLogger.test('Alice waits for presentation request from Faber') + aliceProofExchangeRecord = await aliceProofExchangeRecordPromise + + const request = await faberAgent.proofs.findRequestMessage(faberProofExchangeRecord.id) + + expect(request).toMatchObject({ + type: 'https://didcomm.org/present-proof/2.0/request-presentation', + formats: [ + { + attachmentId: expect.any(String), + format: 'anoncreds/proof-request@v1.0', + }, + ], + requestAttachments: [ + { + id: expect.any(String), + mimeType: 'application/json', + data: { + base64: expect.any(String), + }, + }, + ], + id: expect.any(String), + }) + + expect(aliceProofExchangeRecord.id).not.toBeNull() + expect(aliceProofExchangeRecord).toMatchObject({ + threadId: aliceProofExchangeRecord.threadId, + state: ProofState.RequestReceived, + protocolVersion: 'v2', + }) + + const faberProofExchangeRecordPromise = waitForProofExchangeRecord(faberAgent, { + threadId: aliceProofExchangeRecord.threadId, + state: ProofState.Abandoned, + }) + + aliceProofExchangeRecord = await aliceAgent.proofs.sendProblemReport({ + description: 'Problem inside proof request', + proofRecordId: aliceProofExchangeRecord.id, + }) + + faberProofExchangeRecord = await faberProofExchangeRecordPromise + + expect(faberProofExchangeRecord).toMatchObject({ + threadId: aliceProofExchangeRecord.threadId, + state: ProofState.Abandoned, + protocolVersion: 'v2', + }) + }) + + test('Credential is revoked after proof request and before presentation', async () => { + let aliceProofExchangeRecordPromise = waitForProofExchangeRecord(aliceAgent, { + state: ProofState.RequestReceived, + }) + + const nrpRequestedTime = dateToTimestamp(new Date()) + 1 + + const requestProofFormat: AnonCredsRequestProofFormat = { + non_revoked: { from: nrpRequestedTime, to: nrpRequestedTime }, + name: 'Proof Request', + version: '1.0.0', + requested_attributes: { + name: { + name: 'name', + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + image_0: { + name: 'image_0', + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + }, + requested_predicates: { + age: { + name: 'age', + p_type: '>=', + p_value: 50, + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + }, + } + + // Faber sends a presentation request to Alice + testLogger.test('Faber sends a presentation request to Alice') + faberProofExchangeRecord = await faberAgent.proofs.requestProof({ + protocolVersion: 'v2', + connectionId: faberConnectionId, + proofFormats: { + anoncreds: requestProofFormat, + }, + }) + + // Alice waits for presentation request from Faber + testLogger.test('Alice waits for presentation request from Faber') + aliceProofExchangeRecord = await aliceProofExchangeRecordPromise + + // Alice retrieves the requested credentials and accepts the presentation request + testLogger.test('Alice accepts presentation request from Faber') + + const requestedCredentials = await aliceAgent.proofs.selectCredentialsForRequest({ + proofRecordId: aliceProofExchangeRecord.id, + }) + + const faberProofExchangeRecordPromise = waitForProofExchangeRecord(faberAgent, { + threadId: aliceProofExchangeRecord.threadId, + state: ProofState.PresentationReceived, + }) + + // Revoke the credential + const credentialRevocationRegistryDefinitionId = faberCredentialExchangeRecord.getTag( + 'anonCredsRevocationRegistryId' + ) as string + const credentialRevocationIndex = faberCredentialExchangeRecord.getTag('anonCredsCredentialRevocationId') as string + + expect(credentialRevocationRegistryDefinitionId).toBeDefined() + expect(credentialRevocationIndex).toBeDefined() + + // FIXME: do not use delays. Maybe we can add the timestamp to parameters? + // InMemoryAnonCredsRegistry would respect what we ask while actual VDRs will use their own + await sleep(2000) + await faberAgent.modules.anoncreds.updateRevocationStatusList({ + revocationRegistryDefinitionId: credentialRevocationRegistryDefinitionId, + revokedCredentialIndexes: [Number(credentialRevocationIndex)], + }) + + await aliceAgent.proofs.acceptRequest({ + proofRecordId: aliceProofExchangeRecord.id, + proofFormats: { anoncreds: requestedCredentials.proofFormats.anoncreds }, + }) + + // Faber waits until it receives a presentation from Alice + testLogger.test('Faber waits for presentation from Alice') + faberProofExchangeRecord = await faberProofExchangeRecordPromise + + aliceProofExchangeRecordPromise = waitForProofExchangeRecord(aliceAgent, { + threadId: aliceProofExchangeRecord.threadId, + state: ProofState.Done, + }) + + // Faber accepts the presentation + testLogger.test('Faber accept the presentation from Alice') + await faberAgent.proofs.acceptPresentation({ proofRecordId: faberProofExchangeRecord.id }) + + // Alice waits until she receives a presentation acknowledgement + testLogger.test('Alice waits for acceptance by Faber') + aliceProofExchangeRecord = await aliceProofExchangeRecordPromise + + expect(faberProofExchangeRecord).toMatchObject({ + threadId: aliceProofExchangeRecord.threadId, + isVerified: true, + state: ProofState.PresentationReceived, + }) + + expect(aliceProofExchangeRecord).toMatchObject({ + threadId: faberProofExchangeRecord.threadId, + state: ProofState.Done, + }) + }) + + test.only('Credential is revoked before proof request', async () => { + // Revoke the credential + const credentialRevocationRegistryDefinitionId = faberCredentialExchangeRecord.getTag( + 'anonCredsRevocationRegistryId' + ) as string + const credentialRevocationIndex = faberCredentialExchangeRecord.getTag('anonCredsCredentialRevocationId') as string + + expect(credentialRevocationRegistryDefinitionId).toBeDefined() + expect(credentialRevocationIndex).toBeDefined() + + const { revocationStatusListState } = await faberAgent.modules.anoncreds.updateRevocationStatusList({ + revocationRegistryDefinitionId: credentialRevocationRegistryDefinitionId, + revokedCredentialIndexes: [Number(credentialRevocationIndex)], + }) + + expect(revocationStatusListState.revocationStatusList).toBeDefined() + const revokedTimestamp = revocationStatusListState.revocationStatusList?.timestamp + + const aliceProofExchangeRecordPromise = waitForProofExchangeRecord(aliceAgent, { + state: ProofState.RequestReceived, + }) + + const nrpRequestedTime = (revokedTimestamp ?? dateToTimestamp(new Date())) + 1 + + const requestProofFormat: AnonCredsRequestProofFormat = { + non_revoked: { from: nrpRequestedTime, to: nrpRequestedTime }, + name: 'Proof Request', + version: '1.0.0', + requested_attributes: { + name: { + name: 'name', + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + image_0: { + name: 'image_0', + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + }, + requested_predicates: { + age: { + name: 'age', + p_type: '>=', + p_value: 50, + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + }, + } + + // Faber sends a presentation request to Alice + testLogger.test('Faber sends a presentation request to Alice') + faberProofExchangeRecord = await faberAgent.proofs.requestProof({ + protocolVersion: 'v2', + connectionId: faberConnectionId, + proofFormats: { + anoncreds: requestProofFormat, + }, + }) + + // Alice waits for presentation request from Faber + testLogger.test('Alice waits for presentation request from Faber') + aliceProofExchangeRecord = await aliceProofExchangeRecordPromise + + // Alice retrieves the requested credentials and accepts the presentation request + testLogger.test('Alice accepts presentation request from Faber') + + const requestedCredentials = await aliceAgent.proofs.selectCredentialsForRequest({ + proofRecordId: aliceProofExchangeRecord.id, + proofFormats: { anoncreds: { filterByNonRevocationRequirements: false } }, + }) + + const faberProofExchangeRecordPromise = waitForProofExchangeRecord(faberAgent, { + threadId: aliceProofExchangeRecord.threadId, + }) + + await aliceAgent.proofs.acceptRequest({ + proofRecordId: aliceProofExchangeRecord.id, + proofFormats: { anoncreds: requestedCredentials.proofFormats.anoncreds }, + }) + + // Faber waits until it receives a presentation from Alice + testLogger.test('Faber waits for presentation from Alice') + faberProofExchangeRecord = await faberProofExchangeRecordPromise + + // Faber receives presentation and checks that it is not valid + expect(faberProofExchangeRecord).toMatchObject({ + threadId: aliceProofExchangeRecord.threadId, + isVerified: false, + state: ProofState.Abandoned, + }) + + // Faber will send a problem report, meaning for Alice that the proof state is abandoned + // as well + await waitForProofExchangeRecord(aliceAgent, { + threadId: aliceProofExchangeRecord.threadId, + state: ProofState.Abandoned, + }) + }) +}) diff --git a/packages/anoncreds/src/AnonCredsApi.ts b/packages/anoncreds/src/AnonCredsApi.ts index 5659f5a813..92d741308b 100644 --- a/packages/anoncreds/src/AnonCredsApi.ts +++ b/packages/anoncreds/src/AnonCredsApi.ts @@ -1,6 +1,9 @@ import type { AnonCredsCreateLinkSecretOptions, AnonCredsRegisterCredentialDefinitionOptions, + AnonCredsRegisterRevocationRegistryDefinitionOptions, + AnonCredsRegisterRevocationStatusListOptions, + AnonCredsUpdateRevocationStatusListOptions, } from './AnonCredsApiOptions' import type { AnonCredsCredentialDefinition, AnonCredsSchema } from './models' import type { @@ -12,6 +15,8 @@ import type { GetSchemaReturn, RegisterCredentialDefinitionReturn, RegisterSchemaReturn, + RegisterRevocationRegistryDefinitionReturn, + RegisterRevocationStatusListReturn, } from './services' import type { Extensible } from './services/registry/base' import type { SimpleQuery } from '@aries-framework/core' @@ -21,17 +26,23 @@ import { AgentContext, inject, injectable } from '@aries-framework/core' import { AnonCredsModuleConfig } from './AnonCredsModuleConfig' import { AnonCredsStoreRecordError } from './error' import { + AnonCredsRevocationRegistryDefinitionPrivateRecord, + AnonCredsRevocationRegistryDefinitionPrivateRepository, + AnonCredsRevocationRegistryDefinitionRepository, AnonCredsCredentialDefinitionPrivateRecord, AnonCredsCredentialDefinitionPrivateRepository, AnonCredsKeyCorrectnessProofRecord, AnonCredsKeyCorrectnessProofRepository, AnonCredsLinkSecretRepository, + AnonCredsRevocationRegistryDefinitionRecord, + AnonCredsRevocationRegistryState, } from './repository' import { AnonCredsCredentialDefinitionRecord } from './repository/AnonCredsCredentialDefinitionRecord' import { AnonCredsCredentialDefinitionRepository } from './repository/AnonCredsCredentialDefinitionRepository' import { AnonCredsSchemaRecord } from './repository/AnonCredsSchemaRecord' import { AnonCredsSchemaRepository } from './repository/AnonCredsSchemaRepository' import { AnonCredsCredentialDefinitionRecordMetadataKeys } from './repository/anonCredsCredentialDefinitionRecordMetadataTypes' +import { AnonCredsRevocationRegistryDefinitionRecordMetadataKeys } from './repository/anonCredsRevocationRegistryDefinitionRecordMetadataTypes' import { AnonCredsHolderService, AnonCredsHolderServiceSymbol, @@ -39,7 +50,7 @@ import { AnonCredsIssuerServiceSymbol, } from './services' import { AnonCredsRegistryService } from './services/registry/AnonCredsRegistryService' -import { storeLinkSecret } from './utils' +import { dateToTimestamp, storeLinkSecret } from './utils' @injectable() export class AnonCredsApi { @@ -50,6 +61,8 @@ export class AnonCredsApi { private anonCredsSchemaRepository: AnonCredsSchemaRepository private anonCredsCredentialDefinitionRepository: AnonCredsCredentialDefinitionRepository private anonCredsCredentialDefinitionPrivateRepository: AnonCredsCredentialDefinitionPrivateRepository + private anonCredsRevocationRegistryDefinitionRepository: AnonCredsRevocationRegistryDefinitionRepository + private anonCredsRevocationRegistryDefinitionPrivateRepository: AnonCredsRevocationRegistryDefinitionPrivateRepository private anonCredsKeyCorrectnessProofRepository: AnonCredsKeyCorrectnessProofRepository private anonCredsLinkSecretRepository: AnonCredsLinkSecretRepository private anonCredsIssuerService: AnonCredsIssuerService @@ -62,6 +75,8 @@ export class AnonCredsApi { @inject(AnonCredsIssuerServiceSymbol) anonCredsIssuerService: AnonCredsIssuerService, @inject(AnonCredsHolderServiceSymbol) anonCredsHolderService: AnonCredsHolderService, anonCredsSchemaRepository: AnonCredsSchemaRepository, + anonCredsRevocationRegistryDefinitionRepository: AnonCredsRevocationRegistryDefinitionRepository, + anonCredsRevocationRegistryDefinitionPrivateRepository: AnonCredsRevocationRegistryDefinitionPrivateRepository, anonCredsCredentialDefinitionRepository: AnonCredsCredentialDefinitionRepository, anonCredsCredentialDefinitionPrivateRepository: AnonCredsCredentialDefinitionPrivateRepository, anonCredsKeyCorrectnessProofRepository: AnonCredsKeyCorrectnessProofRepository, @@ -73,6 +88,8 @@ export class AnonCredsApi { this.anonCredsIssuerService = anonCredsIssuerService this.anonCredsHolderService = anonCredsHolderService this.anonCredsSchemaRepository = anonCredsSchemaRepository + this.anonCredsRevocationRegistryDefinitionRepository = anonCredsRevocationRegistryDefinitionRepository + this.anonCredsRevocationRegistryDefinitionPrivateRepository = anonCredsRevocationRegistryDefinitionPrivateRepository this.anonCredsCredentialDefinitionRepository = anonCredsCredentialDefinitionRepository this.anonCredsCredentialDefinitionPrivateRepository = anonCredsCredentialDefinitionPrivateRepository this.anonCredsKeyCorrectnessProofRepository = anonCredsKeyCorrectnessProofRepository @@ -258,7 +275,7 @@ export class AnonCredsApi { issuerId: options.credentialDefinition.issuerId, schemaId: options.credentialDefinition.schemaId, tag: options.credentialDefinition.tag, - supportRevocation: false, + supportRevocation: options.supportRevocation, schema: schemaResult.schema, }, // FIXME: Indy SDK requires the schema seq no to be passed in here. This is not ideal. @@ -338,6 +355,77 @@ export class AnonCredsApi { } } + public async registerRevocationRegistryDefinition(options: { + revocationRegistryDefinition: AnonCredsRegisterRevocationRegistryDefinitionOptions + options: Extensible + }): Promise { + const { issuerId, tag, credentialDefinitionId, maximumCredentialNumber } = options.revocationRegistryDefinition + + const tailsFileService = this.agentContext.dependencyManager.resolve(AnonCredsModuleConfig).tailsFileService + + const tailsDirectoryPath = await tailsFileService.getTailsBasePath(this.agentContext) + + const failedReturnBase = { + revocationRegistryDefinitionState: { + state: 'failed' as const, + reason: `Error registering revocation registry definition for issuerId ${issuerId}`, + }, + registrationMetadata: {}, + revocationRegistryDefinitionMetadata: {}, + } + + const registry = this.findRegistryForIdentifier(issuerId) + if (!registry) { + failedReturnBase.revocationRegistryDefinitionState.reason = `Unable to register revocation registry definition. No registry found for issuerId ${issuerId}` + return failedReturnBase + } + + const { credentialDefinition } = await registry.getCredentialDefinition(this.agentContext, credentialDefinitionId) + + if (!credentialDefinition) { + failedReturnBase.revocationRegistryDefinitionState.reason = `Unable to register revocation registry definition. No credential definition found for id ${credentialDefinitionId}` + return failedReturnBase + } + try { + const { revocationRegistryDefinition, revocationRegistryDefinitionPrivate } = + await this.anonCredsIssuerService.createRevocationRegistryDefinition(this.agentContext, { + issuerId, + tag, + credentialDefinitionId, + credentialDefinition, + maximumCredentialNumber, + tailsDirectoryPath, + }) + + // At this moment, tails file should be published and a valid public URL will be received + const localTailsLocation = revocationRegistryDefinition.value.tailsLocation + + revocationRegistryDefinition.value.tailsLocation = await tailsFileService.uploadTailsFile(this.agentContext, { + revocationRegistryDefinition, + }) + + const result = await registry.registerRevocationRegistryDefinition(this.agentContext, { + revocationRegistryDefinition, + options: {}, + }) + await this.storeRevocationRegistryDefinitionRecord(result, revocationRegistryDefinitionPrivate) + + return { + ...result, + revocationRegistryDefinitionMetadata: { ...result.revocationRegistryDefinitionMetadata, localTailsLocation }, + } + } catch (error) { + // Storage failed + if (error instanceof AnonCredsStoreRecordError) { + failedReturnBase.revocationRegistryDefinitionState.reason = `Error storing revocation registry definition records: ${error.message}` + return failedReturnBase + } + + failedReturnBase.revocationRegistryDefinitionState.reason = `Error registering revocation registry definition: ${error.message}` + return failedReturnBase + } + } + /** * Retrieve the {@link AnonCredsRevocationStatusList} for the given {@link timestamp} from the registry associated * with the {@link revocationRegistryDefinitionId} @@ -374,6 +462,139 @@ export class AnonCredsApi { } } + public async registerRevocationStatusList(options: { + revocationStatusList: AnonCredsRegisterRevocationStatusListOptions + options: Extensible + }): Promise { + const { issuerId, revocationRegistryDefinitionId } = options.revocationStatusList + + const failedReturnBase = { + revocationStatusListState: { + state: 'failed' as const, + reason: `Error registering revocation status list for issuerId ${issuerId}`, + }, + registrationMetadata: {}, + revocationStatusListMetadata: {}, + } + + const registry = this.findRegistryForIdentifier(issuerId) + if (!registry) { + failedReturnBase.revocationStatusListState.reason = `Unable to register revocation status list. No registry found for issuerId ${issuerId}` + return failedReturnBase + } + + const { revocationRegistryDefinition } = await registry.getRevocationRegistryDefinition( + this.agentContext, + revocationRegistryDefinitionId + ) + + if (!revocationRegistryDefinition) { + failedReturnBase.revocationStatusListState.reason = `Unable to register revocation status list. No revocation registry definition found for ${revocationRegistryDefinitionId}` + return failedReturnBase + } + const tailsFileService = this.agentContext.dependencyManager.resolve(AnonCredsModuleConfig).tailsFileService + const tailsFilePath = await tailsFileService.getTailsFile(this.agentContext, { + revocationRegistryDefinition, + }) + + try { + const revocationStatusList = await this.anonCredsIssuerService.createRevocationStatusList(this.agentContext, { + issuerId, + revocationRegistryDefinition, + revocationRegistryDefinitionId, + tailsFilePath, + }) + + const result = await registry.registerRevocationStatusList(this.agentContext, { + revocationStatusList, + options: {}, + }) + + return result + } catch (error) { + // Storage failed + if (error instanceof AnonCredsStoreRecordError) { + failedReturnBase.revocationStatusListState.reason = `Error storing revocation status list records: ${error.message}` + return failedReturnBase + } + + failedReturnBase.revocationStatusListState.reason = `Error registering revocation status list: ${error.message}` + return failedReturnBase + } + } + + public async updateRevocationStatusList( + options: AnonCredsUpdateRevocationStatusListOptions + ): Promise { + const { issuedCredentialIndexes, revokedCredentialIndexes, revocationRegistryDefinitionId } = options + + const failedReturnBase = { + revocationStatusListState: { + state: 'failed' as const, + reason: `Error updating revocation status list for revocation registry definition id ${options.revocationRegistryDefinitionId}`, + }, + registrationMetadata: {}, + revocationStatusListMetadata: {}, + } + + const registry = this.findRegistryForIdentifier(options.revocationRegistryDefinitionId) + if (!registry) { + failedReturnBase.revocationStatusListState.reason = `Unable to update revocation status list. No registry found for id ${options.revocationRegistryDefinitionId}` + return failedReturnBase + } + + const { revocationRegistryDefinition } = await registry.getRevocationRegistryDefinition( + this.agentContext, + revocationRegistryDefinitionId + ) + + if (!revocationRegistryDefinition) { + failedReturnBase.revocationStatusListState.reason = `Unable to update revocation status list. No revocation registry definition found for ${revocationRegistryDefinitionId}` + return failedReturnBase + } + + const { revocationStatusList: previousRevocationStatusList } = await this.getRevocationStatusList( + revocationRegistryDefinitionId, + dateToTimestamp(new Date()) + ) + + if (!previousRevocationStatusList) { + failedReturnBase.revocationStatusListState.reason = `Unable to update revocation status list. No previous revocation status list found for ${options.revocationRegistryDefinitionId}` + return failedReturnBase + } + + const tailsFileService = this.agentContext.dependencyManager.resolve(AnonCredsModuleConfig).tailsFileService + const tailsFilePath = await tailsFileService.getTailsFile(this.agentContext, { + revocationRegistryDefinition, + }) + + try { + const revocationStatusList = await this.anonCredsIssuerService.updateRevocationStatusList(this.agentContext, { + issued: issuedCredentialIndexes, + revoked: revokedCredentialIndexes, + revocationStatusList: previousRevocationStatusList, + revocationRegistryDefinition, + tailsFilePath, + }) + + const result = await registry.registerRevocationStatusList(this.agentContext, { + revocationStatusList, + options: {}, + }) + + return result + } catch (error) { + // Storage failed + if (error instanceof AnonCredsStoreRecordError) { + failedReturnBase.revocationStatusListState.reason = `Error storing revocation status list records: ${error.message}` + return failedReturnBase + } + + failedReturnBase.revocationStatusListState.reason = `Error registering revocation status list: ${error.message}` + return failedReturnBase + } + } + public async getCredential(credentialId: string) { return this.anonCredsHolderService.getCredential(this.agentContext, { credentialId }) } @@ -382,6 +603,59 @@ export class AnonCredsApi { return this.anonCredsHolderService.getCredentials(this.agentContext, options) } + private async storeRevocationRegistryDefinitionRecord( + result: RegisterRevocationRegistryDefinitionReturn, + revocationRegistryDefinitionPrivate?: Record + ): Promise { + try { + // If we have both the revocationRegistryDefinition and the revocationRegistryDefinitionId we will store a copy + // of the credential definition. We may need to handle an edge case in the future where we e.g. don't have the + // id yet, and it is registered through a different channel + if ( + result.revocationRegistryDefinitionState.revocationRegistryDefinition && + result.revocationRegistryDefinitionState.revocationRegistryDefinitionId + ) { + const revocationRegistryDefinitionRecord = new AnonCredsRevocationRegistryDefinitionRecord({ + revocationRegistryDefinitionId: result.revocationRegistryDefinitionState.revocationRegistryDefinitionId, + revocationRegistryDefinition: result.revocationRegistryDefinitionState.revocationRegistryDefinition, + }) + + // TODO: do we need to store this metadata? For indy, the registration metadata contains e.g. + // the indyLedgerSeqNo and the didIndyNamespace, but it can get quite big if complete transactions + // are stored in the metadata + revocationRegistryDefinitionRecord.metadata.set( + AnonCredsRevocationRegistryDefinitionRecordMetadataKeys.RevocationRegistryDefinitionMetadata, + result.revocationRegistryDefinitionMetadata + ) + revocationRegistryDefinitionRecord.metadata.set( + AnonCredsRevocationRegistryDefinitionRecordMetadataKeys.RevocationRegistryDefinitionRegistrationMetadata, + result.registrationMetadata + ) + + await this.anonCredsRevocationRegistryDefinitionRepository.save( + this.agentContext, + revocationRegistryDefinitionRecord + ) + + // Store Revocation Registry Definition private data (if provided by issuer service) + if (revocationRegistryDefinitionPrivate) { + const revocationRegistryDefinitionPrivateRecord = new AnonCredsRevocationRegistryDefinitionPrivateRecord({ + revocationRegistryDefinitionId: result.revocationRegistryDefinitionState.revocationRegistryDefinitionId, + credentialDefinitionId: result.revocationRegistryDefinitionState.revocationRegistryDefinition.credDefId, + value: revocationRegistryDefinitionPrivate, + state: AnonCredsRevocationRegistryState.Active, + }) + await this.anonCredsRevocationRegistryDefinitionPrivateRepository.save( + this.agentContext, + revocationRegistryDefinitionPrivateRecord + ) + } + } + } catch (error) { + throw new AnonCredsStoreRecordError(`Error storing revocation registry definition records`, { cause: error }) + } + } + private async storeCredentialDefinitionPrivateAndKeyCorrectnessRecord( result: RegisterCredentialDefinitionReturn, credentialDefinitionPrivate?: Record, @@ -482,6 +756,7 @@ export class AnonCredsApi { interface AnonCredsRegisterCredentialDefinition { credentialDefinition: AnonCredsRegisterCredentialDefinitionOptions + supportRevocation: boolean options: T } diff --git a/packages/anoncreds/src/AnonCredsApiOptions.ts b/packages/anoncreds/src/AnonCredsApiOptions.ts index f70e422ec4..64bb1e1d61 100644 --- a/packages/anoncreds/src/AnonCredsApiOptions.ts +++ b/packages/anoncreds/src/AnonCredsApiOptions.ts @@ -1,10 +1,28 @@ -import type { AnonCredsCredentialDefinition } from './models' - export interface AnonCredsCreateLinkSecretOptions { linkSecretId?: string setAsDefault?: boolean } -export type AnonCredsRegisterCredentialDefinitionOptions = - | Omit - | AnonCredsCredentialDefinition +export interface AnonCredsRegisterCredentialDefinitionOptions { + issuerId: string + schemaId: string + tag: string +} + +export interface AnonCredsRegisterRevocationRegistryDefinitionOptions { + issuerId: string + tag: string + credentialDefinitionId: string + maximumCredentialNumber: number +} + +export interface AnonCredsRegisterRevocationStatusListOptions { + issuerId: string + revocationRegistryDefinitionId: string +} + +export interface AnonCredsUpdateRevocationStatusListOptions { + revokedCredentialIndexes?: number[] + issuedCredentialIndexes?: number[] + revocationRegistryDefinitionId: string +} diff --git a/packages/anoncreds/src/AnonCredsModule.ts b/packages/anoncreds/src/AnonCredsModule.ts index 873288348c..afc698beda 100644 --- a/packages/anoncreds/src/AnonCredsModule.ts +++ b/packages/anoncreds/src/AnonCredsModule.ts @@ -7,6 +7,8 @@ import { AnonCredsCredentialDefinitionPrivateRepository, AnonCredsKeyCorrectnessProofRepository, AnonCredsLinkSecretRepository, + AnonCredsRevocationRegistryDefinitionPrivateRepository, + AnonCredsRevocationRegistryDefinitionRepository, } from './repository' import { AnonCredsCredentialDefinitionRepository } from './repository/AnonCredsCredentialDefinitionRepository' import { AnonCredsSchemaRepository } from './repository/AnonCredsSchemaRepository' @@ -36,6 +38,8 @@ export class AnonCredsModule implements Module { dependencyManager.registerSingleton(AnonCredsCredentialDefinitionPrivateRepository) dependencyManager.registerSingleton(AnonCredsKeyCorrectnessProofRepository) dependencyManager.registerSingleton(AnonCredsLinkSecretRepository) + dependencyManager.registerSingleton(AnonCredsRevocationRegistryDefinitionRepository) + dependencyManager.registerSingleton(AnonCredsRevocationRegistryDefinitionPrivateRepository) } public updates = [ diff --git a/packages/anoncreds/src/AnonCredsModuleConfig.ts b/packages/anoncreds/src/AnonCredsModuleConfig.ts index 9f7b971aab..c0cbbf9b48 100644 --- a/packages/anoncreds/src/AnonCredsModuleConfig.ts +++ b/packages/anoncreds/src/AnonCredsModuleConfig.ts @@ -1,4 +1,7 @@ import type { AnonCredsRegistry } from './services' +import type { TailsFileService } from './services/tails' + +import { BasicTailsFileService } from './services/tails' /** * @public @@ -9,6 +12,12 @@ export interface AnonCredsModuleConfigOptions { * A list of AnonCreds registries to make available to the AnonCreds module. */ registries: [AnonCredsRegistry, ...AnonCredsRegistry[]] + + /** + * Tails file service for download/uploading tails files + * @default BasicTailsFileService (only for downloading tails files) + */ + tailsFileService?: TailsFileService } /** @@ -25,4 +34,9 @@ export class AnonCredsModuleConfig { public get registries() { return this.options.registries } + + /** See {@link AnonCredsModuleConfigOptions.tailsFileService} */ + public get tailsFileService() { + return this.options.tailsFileService ?? new BasicTailsFileService() + } } diff --git a/packages/anoncreds/src/__tests__/AnonCredsModule.test.ts b/packages/anoncreds/src/__tests__/AnonCredsModule.test.ts index f9c868c14c..02d0d13076 100644 --- a/packages/anoncreds/src/__tests__/AnonCredsModule.test.ts +++ b/packages/anoncreds/src/__tests__/AnonCredsModule.test.ts @@ -9,6 +9,8 @@ import { AnonCredsCredentialDefinitionPrivateRepository, AnonCredsKeyCorrectnessProofRepository, AnonCredsLinkSecretRepository, + AnonCredsRevocationRegistryDefinitionPrivateRepository, + AnonCredsRevocationRegistryDefinitionRepository, } from '../repository' import { AnonCredsRegistryService } from '../services/registry/AnonCredsRegistryService' @@ -26,13 +28,17 @@ describe('AnonCredsModule', () => { }) anonCredsModule.register(dependencyManager) - expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(6) + expect(dependencyManager.registerSingleton).toHaveBeenCalledTimes(8) expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(AnonCredsRegistryService) expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(AnonCredsSchemaRepository) expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(AnonCredsCredentialDefinitionRepository) expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(AnonCredsCredentialDefinitionPrivateRepository) expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(AnonCredsKeyCorrectnessProofRepository) expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(AnonCredsLinkSecretRepository) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith(AnonCredsRevocationRegistryDefinitionRepository) + expect(dependencyManager.registerSingleton).toHaveBeenCalledWith( + AnonCredsRevocationRegistryDefinitionPrivateRepository + ) expect(dependencyManager.registerInstance).toHaveBeenCalledTimes(1) expect(dependencyManager.registerInstance).toHaveBeenCalledWith(AnonCredsModuleConfig, anonCredsModule.config) diff --git a/packages/anoncreds/src/formats/AnonCredsCredentialFormat.ts b/packages/anoncreds/src/formats/AnonCredsCredentialFormat.ts index dba5361a41..716265588a 100644 --- a/packages/anoncreds/src/formats/AnonCredsCredentialFormat.ts +++ b/packages/anoncreds/src/formats/AnonCredsCredentialFormat.ts @@ -42,6 +42,8 @@ export interface AnonCredsProposeCredentialFormat { */ export interface AnonCredsAcceptProposalFormat { credentialDefinitionId?: string + revocationRegistryDefinitionId?: string + revocationRegistryIndex?: number attributes?: CredentialPreviewAttributeOptions[] linkedAttachments?: LinkedAttachment[] } @@ -60,6 +62,8 @@ export interface AnonCredsAcceptOfferFormat { */ export interface AnonCredsOfferCredentialFormat { credentialDefinitionId: string + revocationRegistryDefinitionId?: string + revocationRegistryIndex?: number attributes: CredentialPreviewAttributeOptions[] linkedAttachments?: LinkedAttachment[] } diff --git a/packages/anoncreds/src/formats/AnonCredsCredentialFormatService.ts b/packages/anoncreds/src/formats/AnonCredsCredentialFormatService.ts index 28d7d47185..9a53590d12 100644 --- a/packages/anoncreds/src/formats/AnonCredsCredentialFormatService.ts +++ b/packages/anoncreds/src/formats/AnonCredsCredentialFormatService.ts @@ -43,8 +43,14 @@ import { import { AnonCredsError } from '../error' import { AnonCredsCredentialProposal } from '../models/AnonCredsCredentialProposal' +import { + AnonCredsCredentialDefinitionRepository, + AnonCredsRevocationRegistryDefinitionPrivateRepository, + AnonCredsRevocationRegistryState, +} from '../repository' import { AnonCredsIssuerServiceSymbol, AnonCredsHolderServiceSymbol } from '../services' import { AnonCredsRegistryService } from '../services/registry/AnonCredsRegistryService' +import { dateToTimestamp } from '../utils' import { convertAttributesToCredentialValues, assertCredentialValuesMatch, @@ -160,6 +166,8 @@ export class AnonCredsCredentialFormatService implements CredentialFormatService attachmentId, attributes, credentialDefinitionId, + revocationRegistryDefinitionId: anoncredsFormat?.revocationRegistryDefinitionId, + revocationRegistryIndex: anoncredsFormat?.revocationRegistryIndex, linkedAttachments: anoncredsFormat?.linkedAttachments, }) @@ -188,6 +196,8 @@ export class AnonCredsCredentialFormatService implements CredentialFormatService attachmentId, attributes: anoncredsFormat.attributes, credentialDefinitionId: anoncredsFormat.credentialDefinitionId, + revocationRegistryDefinitionId: anoncredsFormat.revocationRegistryDefinitionId, + revocationRegistryIndex: anoncredsFormat.revocationRegistryIndex, linkedAttachments: anoncredsFormat.linkedAttachments, }) @@ -303,23 +313,65 @@ export class AnonCredsCredentialFormatService implements CredentialFormatService const credentialRequest = requestAttachment.getDataAsJson() if (!credentialRequest) throw new AriesFrameworkError('Missing anoncreds credential request in createCredential') - const { credential, credentialRevocationId } = await anonCredsIssuerService.createCredential(agentContext, { + // We check locally for credential definition info. If it supports revocation, we need to search locally for + // an active revocation registry + const credentialDefinition = ( + await agentContext.dependencyManager + .resolve(AnonCredsCredentialDefinitionRepository) + .getByCredentialDefinitionId(agentContext, credentialRequest.cred_def_id) + ).credentialDefinition.value + + let revocationRegistryDefinitionId + let revocationRegistryIndex + let revocationStatusList + + if (credentialDefinition.revocation) { + const credentialMetadata = + credentialRecord.metadata.get(AnonCredsCredentialMetadataKey) + revocationRegistryDefinitionId = credentialMetadata?.revocationRegistryId + if (credentialMetadata?.credentialRevocationId) { + revocationRegistryIndex = Number(credentialMetadata.credentialRevocationId) + } + + if (!revocationRegistryDefinitionId || !revocationRegistryIndex) { + throw new AriesFrameworkError( + 'Revocation registry definition id and revocation index are mandatory to issue AnonCreds revocable credentials' + ) + } + const revocationRegistryDefinitionPrivateRecord = await agentContext.dependencyManager + .resolve(AnonCredsRevocationRegistryDefinitionPrivateRepository) + .getByRevocationRegistryDefinitionId(agentContext, revocationRegistryDefinitionId) + + if (revocationRegistryDefinitionPrivateRecord.state !== AnonCredsRevocationRegistryState.Active) { + throw new AriesFrameworkError( + `Revocation registry ${revocationRegistryDefinitionId} is in ${revocationRegistryDefinitionPrivateRecord.state} state` + ) + } + + const registryService = agentContext.dependencyManager.resolve(AnonCredsRegistryService) + const revocationStatusListResult = await registryService + .getRegistryForIdentifier(agentContext, revocationRegistryDefinitionId) + .getRevocationStatusList(agentContext, revocationRegistryDefinitionId, dateToTimestamp(new Date())) + + if (!revocationStatusListResult.revocationStatusList) { + throw new AriesFrameworkError( + `Unable to resolve revocation status list for ${revocationRegistryDefinitionId}: + ${revocationStatusListResult.resolutionMetadata.error} ${revocationStatusListResult.resolutionMetadata.message}` + ) + } + + revocationStatusList = revocationStatusListResult.revocationStatusList + } + + const { credential } = await anonCredsIssuerService.createCredential(agentContext, { credentialOffer, credentialRequest, credentialValues: convertAttributesToCredentialValues(credentialAttributes), + revocationRegistryDefinitionId, + revocationRegistryIndex, + revocationStatusList, }) - if (credential.rev_reg_id) { - credentialRecord.metadata.add(AnonCredsCredentialMetadataKey, { - credentialRevocationId: credentialRevocationId, - revocationRegistryId: credential.rev_reg_id, - }) - credentialRecord.setTags({ - anonCredsRevocationRegistryId: credential.rev_reg_id, - anonCredsCredentialRevocationId: credentialRevocationId, - }) - } - const format = new CredentialFormatSpec({ attachmentId, format: ANONCREDS_CREDENTIAL, @@ -524,10 +576,14 @@ export class AnonCredsCredentialFormatService implements CredentialFormatService credentialRecord, attachmentId, credentialDefinitionId, + revocationRegistryDefinitionId, + revocationRegistryIndex, attributes, linkedAttachments, }: { credentialDefinitionId: string + revocationRegistryDefinitionId?: string + revocationRegistryIndex?: number credentialRecord: CredentialExchangeRecord attachmentId?: string attributes: CredentialPreviewAttributeOptions[] @@ -554,9 +610,34 @@ export class AnonCredsCredentialFormatService implements CredentialFormatService await this.assertPreviewAttributesMatchSchemaAttributes(agentContext, offer, previewAttributes) + // We check locally for credential definition info. If it supports revocation, revocationRegistryIndex + // and revocationRegistryDefinitionId are mandatory + const credentialDefinition = ( + await agentContext.dependencyManager + .resolve(AnonCredsCredentialDefinitionRepository) + .getByCredentialDefinitionId(agentContext, offer.cred_def_id) + ).credentialDefinition.value + + if (credentialDefinition.revocation) { + if (!revocationRegistryDefinitionId || !revocationRegistryIndex) { + throw new AriesFrameworkError( + 'AnonCreds revocable credentials require revocationRegistryDefinitionId and revocationRegistryIndex' + ) + } + + // Set revocation tags + credentialRecord.setTags({ + anonCredsRevocationRegistryId: revocationRegistryDefinitionId, + anonCredsCredentialRevocationId: revocationRegistryIndex.toString(), + }) + } + + // Set the metadata credentialRecord.metadata.set(AnonCredsCredentialMetadataKey, { schemaId: offer.schema_id, credentialDefinitionId: offer.cred_def_id, + credentialRevocationId: revocationRegistryIndex?.toString(), + revocationRegistryId: revocationRegistryDefinitionId, }) const attachment = this.getFormatData(offer, format.attachmentId) diff --git a/packages/anoncreds/src/formats/AnonCredsProofFormat.ts b/packages/anoncreds/src/formats/AnonCredsProofFormat.ts index 0c326943f8..c25dd17bc8 100644 --- a/packages/anoncreds/src/formats/AnonCredsProofFormat.ts +++ b/packages/anoncreds/src/formats/AnonCredsProofFormat.ts @@ -34,6 +34,7 @@ export interface AnonCredsProposeProofFormat { version?: string attributes?: AnonCredsPresentationPreviewAttribute[] predicates?: AnonCredsPresentationPreviewPredicate[] + nonRevokedInterval?: AnonCredsNonRevokedInterval } /** diff --git a/packages/anoncreds/src/formats/AnonCredsProofFormatService.ts b/packages/anoncreds/src/formats/AnonCredsProofFormatService.ts index 001aebb340..e996a16e6f 100644 --- a/packages/anoncreds/src/formats/AnonCredsProofFormatService.ts +++ b/packages/anoncreds/src/formats/AnonCredsProofFormatService.ts @@ -58,6 +58,7 @@ import { getRevocationRegistriesForRequest, getRevocationRegistriesForProof, } from '../utils' +import { dateToTimestamp } from '../utils/timestamp' const ANONCREDS_PRESENTATION_PROPOSAL = 'anoncreds/proof-request@v1.0' const ANONCREDS_PRESENTATION_REQUEST = 'anoncreds/proof-request@v1.0' @@ -86,6 +87,7 @@ export class AnonCredsProofFormatService implements ProofFormatService + index?: number + state?: AnonCredsRevocationRegistryState +} + +export type DefaultAnonCredsRevocationRegistryPrivateTags = { + revocationRegistryDefinitionId: string + credentialDefinitionId: string + state: AnonCredsRevocationRegistryState +} + +export class AnonCredsRevocationRegistryDefinitionPrivateRecord extends BaseRecord< + DefaultAnonCredsRevocationRegistryPrivateTags, + TagsBase +> { + public static readonly type = 'AnonCredsRevocationRegistryDefinitionPrivateRecord' + public readonly type = AnonCredsRevocationRegistryDefinitionPrivateRecord.type + + public readonly revocationRegistryDefinitionId!: string + public readonly credentialDefinitionId!: string + public readonly value!: Record // TODO: Define structure + + public state!: AnonCredsRevocationRegistryState + + public constructor(props: AnonCredsRevocationRegistryDefinitionPrivateRecordProps) { + super() + + if (props) { + this.id = props.id ?? utils.uuid() + this.revocationRegistryDefinitionId = props.revocationRegistryDefinitionId + this.credentialDefinitionId = props.credentialDefinitionId + this.value = props.value + this.state = props.state ?? AnonCredsRevocationRegistryState.Created + } + } + + public getTags() { + return { + ...this._tags, + revocationRegistryDefinitionId: this.revocationRegistryDefinitionId, + credentialDefinitionId: this.credentialDefinitionId, + state: this.state, + } + } +} diff --git a/packages/anoncreds/src/repository/AnonCredsRevocationRegistryDefinitionPrivateRepository.ts b/packages/anoncreds/src/repository/AnonCredsRevocationRegistryDefinitionPrivateRepository.ts new file mode 100644 index 0000000000..0f571d2a1a --- /dev/null +++ b/packages/anoncreds/src/repository/AnonCredsRevocationRegistryDefinitionPrivateRepository.ts @@ -0,0 +1,36 @@ +import type { AnonCredsRevocationRegistryState } from './AnonCredsRevocationRegistryDefinitionPrivateRecord' +import type { AgentContext } from '@aries-framework/core' + +import { Repository, InjectionSymbols, StorageService, EventEmitter, injectable, inject } from '@aries-framework/core' + +import { AnonCredsRevocationRegistryDefinitionPrivateRecord } from './AnonCredsRevocationRegistryDefinitionPrivateRecord' + +@injectable() +export class AnonCredsRevocationRegistryDefinitionPrivateRepository extends Repository { + public constructor( + @inject(InjectionSymbols.StorageService) + storageService: StorageService, + eventEmitter: EventEmitter + ) { + super(AnonCredsRevocationRegistryDefinitionPrivateRecord, storageService, eventEmitter) + } + + public async getByRevocationRegistryDefinitionId(agentContext: AgentContext, revocationRegistryDefinitionId: string) { + return this.getSingleByQuery(agentContext, { revocationRegistryDefinitionId }) + } + + public async findByRevocationRegistryDefinitionId( + agentContext: AgentContext, + revocationRegistryDefinitionId: string + ) { + return this.findSingleByQuery(agentContext, { revocationRegistryDefinitionId }) + } + + public async findAllByCredentialDefinitionIdAndState( + agentContext: AgentContext, + credentialDefinitionId: string, + state?: AnonCredsRevocationRegistryState + ) { + return this.findByQuery(agentContext, { credentialDefinitionId, state }) + } +} diff --git a/packages/anoncreds/src/repository/AnonCredsRevocationRegistryDefinitionRecord.ts b/packages/anoncreds/src/repository/AnonCredsRevocationRegistryDefinitionRecord.ts new file mode 100644 index 0000000000..8a41227574 --- /dev/null +++ b/packages/anoncreds/src/repository/AnonCredsRevocationRegistryDefinitionRecord.ts @@ -0,0 +1,46 @@ +import type { AnonCredsRevocationRegistryDefinitionRecordMetadata } from './anonCredsRevocationRegistryDefinitionRecordMetadataTypes' +import type { AnonCredsRevocationRegistryDefinition } from '../models' +import type { TagsBase } from '@aries-framework/core' + +import { BaseRecord, utils } from '@aries-framework/core' + +export interface AnonCredsRevocationRegistryDefinitionRecordProps { + id?: string + revocationRegistryDefinitionId: string + revocationRegistryDefinition: AnonCredsRevocationRegistryDefinition +} + +export type DefaultAnonCredsRevocationRegistryDefinitionTags = { + revocationRegistryDefinitionId: string + credentialDefinitionId: string +} + +export class AnonCredsRevocationRegistryDefinitionRecord extends BaseRecord< + DefaultAnonCredsRevocationRegistryDefinitionTags, + TagsBase, + AnonCredsRevocationRegistryDefinitionRecordMetadata +> { + public static readonly type = 'AnonCredsRevocationRegistryDefinitionRecord' + public readonly type = AnonCredsRevocationRegistryDefinitionRecord.type + + public readonly revocationRegistryDefinitionId!: string + public readonly revocationRegistryDefinition!: AnonCredsRevocationRegistryDefinition + + public constructor(props: AnonCredsRevocationRegistryDefinitionRecordProps) { + super() + + if (props) { + this.id = props.id ?? utils.uuid() + this.revocationRegistryDefinitionId = props.revocationRegistryDefinitionId + this.revocationRegistryDefinition = props.revocationRegistryDefinition + } + } + + public getTags() { + return { + ...this._tags, + revocationRegistryDefinitionId: this.revocationRegistryDefinitionId, + credentialDefinitionId: this.revocationRegistryDefinition.credDefId, + } + } +} diff --git a/packages/anoncreds/src/repository/AnonCredsRevocationRegistryDefinitionRepository.ts b/packages/anoncreds/src/repository/AnonCredsRevocationRegistryDefinitionRepository.ts new file mode 100644 index 0000000000..4b1890b09b --- /dev/null +++ b/packages/anoncreds/src/repository/AnonCredsRevocationRegistryDefinitionRepository.ts @@ -0,0 +1,31 @@ +import type { AgentContext } from '@aries-framework/core' + +import { Repository, InjectionSymbols, StorageService, EventEmitter, injectable, inject } from '@aries-framework/core' + +import { AnonCredsRevocationRegistryDefinitionRecord } from './AnonCredsRevocationRegistryDefinitionRecord' + +@injectable() +export class AnonCredsRevocationRegistryDefinitionRepository extends Repository { + public constructor( + @inject(InjectionSymbols.StorageService) + storageService: StorageService, + eventEmitter: EventEmitter + ) { + super(AnonCredsRevocationRegistryDefinitionRecord, storageService, eventEmitter) + } + + public async getByRevocationRegistryDefinitionId(agentContext: AgentContext, revocationRegistryDefinitionId: string) { + return this.getSingleByQuery(agentContext, { revocationRegistryDefinitionId }) + } + + public async findByRevocationRegistryDefinitionId( + agentContext: AgentContext, + revocationRegistryDefinitionId: string + ) { + return this.findSingleByQuery(agentContext, { revocationRegistryDefinitionId }) + } + + public async findAllByCredentialDefinitionId(agentContext: AgentContext, credentialDefinitionId: string) { + return this.findByQuery(agentContext, { credentialDefinitionId }) + } +} diff --git a/packages/anoncreds/src/repository/anonCredsRevocationRegistryDefinitionRecordMetadataTypes.ts b/packages/anoncreds/src/repository/anonCredsRevocationRegistryDefinitionRecordMetadataTypes.ts new file mode 100644 index 0000000000..7a960c2af1 --- /dev/null +++ b/packages/anoncreds/src/repository/anonCredsRevocationRegistryDefinitionRecordMetadataTypes.ts @@ -0,0 +1,11 @@ +import type { Extensible } from '../services/registry/base' + +export enum AnonCredsRevocationRegistryDefinitionRecordMetadataKeys { + RevocationRegistryDefinitionRegistrationMetadata = '_internal/anonCredsRevocationRegistryDefinitionRegistrationMetadata', + RevocationRegistryDefinitionMetadata = '_internal/anonCredsRevocationRegistryDefinitionMetadata', +} + +export type AnonCredsRevocationRegistryDefinitionRecordMetadata = { + [AnonCredsRevocationRegistryDefinitionRecordMetadataKeys.RevocationRegistryDefinitionRegistrationMetadata]: Extensible + [AnonCredsRevocationRegistryDefinitionRecordMetadataKeys.RevocationRegistryDefinitionMetadata]: Extensible +} diff --git a/packages/anoncreds/src/repository/index.ts b/packages/anoncreds/src/repository/index.ts index c4fb3bbe80..8772b528e7 100644 --- a/packages/anoncreds/src/repository/index.ts +++ b/packages/anoncreds/src/repository/index.ts @@ -8,5 +8,9 @@ export * from './AnonCredsKeyCorrectnessProofRecord' export * from './AnonCredsKeyCorrectnessProofRepository' export * from './AnonCredsLinkSecretRecord' export * from './AnonCredsLinkSecretRepository' +export * from './AnonCredsRevocationRegistryDefinitionRecord' +export * from './AnonCredsRevocationRegistryDefinitionRepository' +export * from './AnonCredsRevocationRegistryDefinitionPrivateRecord' +export * from './AnonCredsRevocationRegistryDefinitionPrivateRepository' export * from './AnonCredsSchemaRecord' export * from './AnonCredsSchemaRepository' diff --git a/packages/anoncreds/src/services/AnonCredsIssuerService.ts b/packages/anoncreds/src/services/AnonCredsIssuerService.ts index 3090b1759b..00f6b5871e 100644 --- a/packages/anoncreds/src/services/AnonCredsIssuerService.ts +++ b/packages/anoncreds/src/services/AnonCredsIssuerService.ts @@ -5,9 +5,13 @@ import type { CreateCredentialReturn, CreateCredentialOptions, CreateCredentialDefinitionReturn, + CreateRevocationRegistryDefinitionOptions, + CreateRevocationRegistryDefinitionReturn, + CreateRevocationStatusListOptions, + UpdateRevocationStatusListOptions, } from './AnonCredsIssuerServiceOptions' import type { AnonCredsCredentialOffer } from '../models/exchange' -import type { AnonCredsSchema } from '../models/registry' +import type { AnonCredsRevocationStatusList, AnonCredsSchema } from '../models/registry' import type { AgentContext } from '@aries-framework/core' export const AnonCredsIssuerServiceSymbol = Symbol('AnonCredsIssuerService') @@ -23,6 +27,21 @@ export interface AnonCredsIssuerService { metadata?: Record ): Promise + createRevocationRegistryDefinition( + agentContext: AgentContext, + options: CreateRevocationRegistryDefinitionOptions + ): Promise + + createRevocationStatusList( + agentContext: AgentContext, + options: CreateRevocationStatusListOptions + ): Promise + + updateRevocationStatusList( + agentContext: AgentContext, + options: UpdateRevocationStatusListOptions + ): Promise + createCredentialOffer( agentContext: AgentContext, options: CreateCredentialOfferOptions diff --git a/packages/anoncreds/src/services/AnonCredsIssuerServiceOptions.ts b/packages/anoncreds/src/services/AnonCredsIssuerServiceOptions.ts index c7da246b9b..936ea91af1 100644 --- a/packages/anoncreds/src/services/AnonCredsIssuerServiceOptions.ts +++ b/packages/anoncreds/src/services/AnonCredsIssuerServiceOptions.ts @@ -4,7 +4,12 @@ import type { AnonCredsCredentialRequest, AnonCredsCredentialValues, } from '../models/exchange' -import type { AnonCredsCredentialDefinition, AnonCredsSchema } from '../models/registry' +import type { + AnonCredsCredentialDefinition, + AnonCredsRevocationRegistryDefinition, + AnonCredsRevocationStatusList, + AnonCredsSchema, +} from '../models/registry' export interface CreateSchemaOptions { issuerId: string @@ -16,12 +21,36 @@ export interface CreateSchemaOptions { export interface CreateCredentialDefinitionOptions { issuerId: string tag: string - supportRevocation?: boolean - + supportRevocation: boolean schemaId: string schema: AnonCredsSchema } +export interface CreateRevocationRegistryDefinitionOptions { + issuerId: string + tag: string + credentialDefinitionId: string + credentialDefinition: AnonCredsCredentialDefinition + maximumCredentialNumber: number + tailsDirectoryPath: string +} + +export interface CreateRevocationStatusListOptions { + issuerId: string + revocationRegistryDefinitionId: string + revocationRegistryDefinition: AnonCredsRevocationRegistryDefinition + tailsFilePath: string +} + +export interface UpdateRevocationStatusListOptions { + revocationStatusList: AnonCredsRevocationStatusList + revocationRegistryDefinition: AnonCredsRevocationRegistryDefinition + revoked?: number[] + issued?: number[] + timestamp?: number + tailsFilePath: string +} + export interface CreateCredentialOfferOptions { credentialDefinitionId: string } @@ -30,9 +59,9 @@ export interface CreateCredentialOptions { credentialOffer: AnonCredsCredentialOffer credentialRequest: AnonCredsCredentialRequest credentialValues: AnonCredsCredentialValues - revocationRegistryId?: string - // TODO: should this just be the tails file instead of a path? - tailsFilePath?: string + revocationRegistryDefinitionId?: string + revocationStatusList?: AnonCredsRevocationStatusList + revocationRegistryIndex?: number } export interface CreateCredentialReturn { @@ -45,3 +74,8 @@ export interface CreateCredentialDefinitionReturn { credentialDefinitionPrivate?: Record keyCorrectnessProof?: Record } + +export interface CreateRevocationRegistryDefinitionReturn { + revocationRegistryDefinition: AnonCredsRevocationRegistryDefinition + revocationRegistryDefinitionPrivate?: Record +} diff --git a/packages/anoncreds/src/services/index.ts b/packages/anoncreds/src/services/index.ts index fe7b176754..419436fde9 100644 --- a/packages/anoncreds/src/services/index.ts +++ b/packages/anoncreds/src/services/index.ts @@ -3,5 +3,6 @@ export * from './AnonCredsHolderServiceOptions' export * from './AnonCredsIssuerService' export * from './AnonCredsIssuerServiceOptions' export * from './registry' +export { TailsFileService, BasicTailsFileService } from './tails' export * from './AnonCredsVerifierService' export * from './AnonCredsVerifierServiceOptions' diff --git a/packages/anoncreds/src/services/registry/AnonCredsRegistry.ts b/packages/anoncreds/src/services/registry/AnonCredsRegistry.ts index 85bf72ba2b..d641befdef 100644 --- a/packages/anoncreds/src/services/registry/AnonCredsRegistry.ts +++ b/packages/anoncreds/src/services/registry/AnonCredsRegistry.ts @@ -3,8 +3,16 @@ import type { RegisterCredentialDefinitionOptions, RegisterCredentialDefinitionReturn, } from './CredentialDefinitionOptions' -import type { GetRevocationRegistryDefinitionReturn } from './RevocationRegistryDefinitionOptions' -import type { GetRevocationStatusListReturn } from './RevocationStatusListOptions' +import type { + GetRevocationRegistryDefinitionReturn, + RegisterRevocationRegistryDefinitionOptions, + RegisterRevocationRegistryDefinitionReturn, +} from './RevocationRegistryDefinitionOptions' +import type { + GetRevocationStatusListReturn, + RegisterRevocationStatusListOptions, + RegisterRevocationStatusListReturn, +} from './RevocationStatusListOptions' import type { GetSchemaReturn, RegisterSchemaOptions, RegisterSchemaReturn } from './SchemaOptions' import type { AgentContext } from '@aries-framework/core' @@ -38,11 +46,10 @@ export interface AnonCredsRegistry { revocationRegistryDefinitionId: string ): Promise - // TODO: issuance of revocable credentials - // registerRevocationRegistryDefinition( - // agentContext: AgentContext, - // options: RegisterRevocationRegistryDefinitionOptions - // ): Promise + registerRevocationRegistryDefinition( + agentContext: AgentContext, + options: RegisterRevocationRegistryDefinitionOptions + ): Promise getRevocationStatusList( agentContext: AgentContext, @@ -50,9 +57,8 @@ export interface AnonCredsRegistry { timestamp: number ): Promise - // TODO: issuance of revocable credentials - // registerRevocationList( - // agentContext: AgentContext, - // options: RegisterRevocationListOptions - // ): Promise + registerRevocationStatusList( + agentContext: AgentContext, + options: RegisterRevocationStatusListOptions + ): Promise } diff --git a/packages/anoncreds/src/services/registry/RevocationRegistryDefinitionOptions.ts b/packages/anoncreds/src/services/registry/RevocationRegistryDefinitionOptions.ts index 6e9d1349fe..3f7a07ed77 100644 --- a/packages/anoncreds/src/services/registry/RevocationRegistryDefinitionOptions.ts +++ b/packages/anoncreds/src/services/registry/RevocationRegistryDefinitionOptions.ts @@ -1,4 +1,10 @@ -import type { AnonCredsResolutionMetadata, Extensible } from './base' +import type { + AnonCredsOperationStateWait, + AnonCredsOperationStateFailed, + AnonCredsOperationStateFinished, + AnonCredsResolutionMetadata, + Extensible, +} from './base' import type { AnonCredsRevocationRegistryDefinition } from '../../models/registry' export interface GetRevocationRegistryDefinitionReturn { @@ -8,11 +14,32 @@ export interface GetRevocationRegistryDefinitionReturn { revocationRegistryDefinitionMetadata: Extensible } -// TODO: Support for issuance of revocable credentials -// export interface RegisterRevocationRegistryDefinitionOptions { -// revocationRegistryDefinition: AnonCredsRevocationRegistryDefinition -// } +export interface RegisterRevocationRegistryDefinitionOptions { + revocationRegistryDefinition: AnonCredsRevocationRegistryDefinition + options: Extensible +} + +export interface RegisterRevocationRegistryDefinitionReturnStateFailed extends AnonCredsOperationStateFailed { + revocationRegistryDefinition?: AnonCredsRevocationRegistryDefinition + revocationRegistryDefinitionId?: string +} + +export interface RegisterRevocationRegistryDefinitionReturnStateFinished extends AnonCredsOperationStateFinished { + revocationRegistryDefinition: AnonCredsRevocationRegistryDefinition + revocationRegistryDefinitionId: string +} + +export interface RegisterRevocationRegistryDefinitionReturnState extends AnonCredsOperationStateWait { + revocationRegistryDefinition?: AnonCredsRevocationRegistryDefinition + revocationRegistryDefinitionId?: string +} -// export interface RegisterRevocationRegistryDefinitionReturn { -// revocationRegistryDefinitionId: string -// } +export interface RegisterRevocationRegistryDefinitionReturn { + jobId?: string + revocationRegistryDefinitionState: + | RegisterRevocationRegistryDefinitionReturnStateFailed + | RegisterRevocationRegistryDefinitionReturnStateFinished + | RegisterRevocationRegistryDefinitionReturnState + revocationRegistryDefinitionMetadata: Extensible + registrationMetadata: Extensible +} diff --git a/packages/anoncreds/src/services/registry/RevocationStatusListOptions.ts b/packages/anoncreds/src/services/registry/RevocationStatusListOptions.ts index 6396fe6df0..05b1353801 100644 --- a/packages/anoncreds/src/services/registry/RevocationStatusListOptions.ts +++ b/packages/anoncreds/src/services/registry/RevocationStatusListOptions.ts @@ -1,4 +1,10 @@ -import type { AnonCredsResolutionMetadata, Extensible } from './base' +import type { + AnonCredsOperationStateWait, + AnonCredsOperationStateFailed, + AnonCredsOperationStateFinished, + AnonCredsResolutionMetadata, + Extensible, +} from './base' import type { AnonCredsRevocationStatusList } from '../../models/registry' export interface GetRevocationStatusListReturn { @@ -7,12 +13,34 @@ export interface GetRevocationStatusListReturn { revocationStatusListMetadata: Extensible } -// TODO: Support for issuance of revocable credentials -// export interface RegisterRevocationListOptions { -// // Timestamp is often calculated by the ledger, otherwise method should just take current time -// // Return type does include the timestamp. -// revocationList: Omit -// } -// export interface RegisterRevocationListReturn { -// timestamp: string -// } +export interface RegisterRevocationStatusListOptions { + // Timestamp is often calculated by the ledger, otherwise method should just take current time + // Return type does include the timestamp. + revocationStatusList: Omit + options: Extensible +} + +export interface RegisterRevocationStatusListReturnStateFailed extends AnonCredsOperationStateFailed { + revocationStatusList?: AnonCredsRevocationStatusList + timestamp?: string +} + +export interface RegisterRevocationStatusListReturnStateFinished extends AnonCredsOperationStateFinished { + revocationStatusList: AnonCredsRevocationStatusList + timestamp: string +} + +export interface RegisterRevocationStatusListReturnState extends AnonCredsOperationStateWait { + revocationStatusList?: AnonCredsRevocationStatusList + timestamp?: string +} + +export interface RegisterRevocationStatusListReturn { + jobId?: string + revocationStatusListState: + | RegisterRevocationStatusListReturnStateFailed + | RegisterRevocationStatusListReturnStateFinished + | RegisterRevocationStatusListReturnState + revocationStatusListMetadata: Extensible + registrationMetadata: Extensible +} diff --git a/packages/anoncreds/src/services/tails/BasicTailsFileService.ts b/packages/anoncreds/src/services/tails/BasicTailsFileService.ts new file mode 100644 index 0000000000..f2cf3eeae1 --- /dev/null +++ b/packages/anoncreds/src/services/tails/BasicTailsFileService.ts @@ -0,0 +1,88 @@ +import type { TailsFileService } from './TailsFileService' +import type { AnonCredsRevocationRegistryDefinition } from '../../models' +import type { AgentContext, FileSystem } from '@aries-framework/core' + +import { AriesFrameworkError, InjectionSymbols, TypedArrayEncoder } from '@aries-framework/core' + +export class BasicTailsFileService implements TailsFileService { + private tailsDirectoryPath?: string + + public constructor(options?: { tailsDirectoryPath?: string; tailsServerBaseUrl?: string }) { + this.tailsDirectoryPath = options?.tailsDirectoryPath + } + + public async getTailsBasePath(agentContext: AgentContext) { + const fileSystem = agentContext.dependencyManager.resolve(InjectionSymbols.FileSystem) + const basePath = `${this.tailsDirectoryPath ?? fileSystem.cachePath}/anoncreds/tails` + if (!(await fileSystem.exists(basePath))) { + await fileSystem.createDirectory(`${basePath}/file`) + } + return basePath + } + + public async uploadTailsFile( + // eslint-disable-next-line @typescript-eslint/no-unused-vars + agentContext: AgentContext, + // eslint-disable-next-line @typescript-eslint/no-unused-vars + options: { + revocationRegistryDefinition: AnonCredsRevocationRegistryDefinition + } + ): Promise { + throw new AriesFrameworkError('BasicTailsFileService only supports tails file downloading') + } + + public async getTailsFile( + agentContext: AgentContext, + options: { + revocationRegistryDefinition: AnonCredsRevocationRegistryDefinition + } + ): Promise { + const { revocationRegistryDefinition } = options + const { tailsLocation, tailsHash } = revocationRegistryDefinition.value + + const fileSystem = agentContext.dependencyManager.resolve(InjectionSymbols.FileSystem) + + try { + agentContext.config.logger.debug( + `Checking to see if tails file for URL ${revocationRegistryDefinition.value.tailsLocation} has been stored in the FileSystem` + ) + + // hash is used as file identifier + const tailsExists = await this.tailsFileExists(agentContext, tailsHash) + const tailsFilePath = await this.getTailsFilePath(agentContext, tailsHash) + agentContext.config.logger.debug( + `Tails file for ${tailsLocation} ${tailsExists ? 'is stored' : 'is not stored'} at ${tailsFilePath}` + ) + + if (!tailsExists) { + agentContext.config.logger.debug(`Retrieving tails file from URL ${tailsLocation}`) + + // download file and verify hash + await fileSystem.downloadToFile(tailsLocation, tailsFilePath, { + verifyHash: { + algorithm: 'sha256', + hash: TypedArrayEncoder.fromBase58(tailsHash), + }, + }) + agentContext.config.logger.debug(`Saved tails file to FileSystem at path ${tailsFilePath}`) + } + + return tailsFilePath + } catch (error) { + agentContext.config.logger.error(`Error while retrieving tails file from URL ${tailsLocation}`, { + error, + }) + throw error + } + } + + protected async getTailsFilePath(agentContext: AgentContext, tailsHash: string) { + return `${await this.getTailsBasePath(agentContext)}/${tailsHash}` + } + + protected async tailsFileExists(agentContext: AgentContext, tailsHash: string): Promise { + const fileSystem = agentContext.dependencyManager.resolve(InjectionSymbols.FileSystem) + const tailsFilePath = await this.getTailsFilePath(agentContext, tailsHash) + return await fileSystem.exists(tailsFilePath) + } +} diff --git a/packages/anoncreds/src/services/tails/TailsFileService.ts b/packages/anoncreds/src/services/tails/TailsFileService.ts new file mode 100644 index 0000000000..d8e0dd7167 --- /dev/null +++ b/packages/anoncreds/src/services/tails/TailsFileService.ts @@ -0,0 +1,47 @@ +import type { AnonCredsRevocationRegistryDefinition } from '../../models' +import type { AgentContext } from '@aries-framework/core' + +export interface TailsFileService { + /** + * Retrieve base directory for tail file storage + * + * @param agentContext + */ + getTailsBasePath(agentContext: AgentContext): string | Promise + + /** + * Upload the tails file for a given revocation registry definition. + * + * Optionally, receives revocationRegistryDefinitionId in case the ID is + * known beforehand. + * + * Returns the published tail file URL + * @param agentContext + * @param options + */ + uploadTailsFile( + agentContext: AgentContext, + options: { + revocationRegistryDefinition: AnonCredsRevocationRegistryDefinition + revocationRegistryDefinitionId?: string + } + ): Promise + + /** + * Retrieve the tails file for a given revocation registry, downloading it + * from the tailsLocation URL if not present in internal cache + * + * Classes implementing this interface should verify integrity of the downloaded + * file. + * + * @param agentContext + * @param options + */ + getTailsFile( + agentContext: AgentContext, + options: { + revocationRegistryDefinition: AnonCredsRevocationRegistryDefinition + revocationRegistryDefinitionId?: string + } + ): Promise +} diff --git a/packages/anoncreds/src/services/tails/index.ts b/packages/anoncreds/src/services/tails/index.ts new file mode 100644 index 0000000000..104617b380 --- /dev/null +++ b/packages/anoncreds/src/services/tails/index.ts @@ -0,0 +1,2 @@ +export * from './BasicTailsFileService' +export * from './TailsFileService' diff --git a/packages/anoncreds/src/utils/createRequestFromPreview.ts b/packages/anoncreds/src/utils/createRequestFromPreview.ts index d1738d000e..5c08ebd83d 100644 --- a/packages/anoncreds/src/utils/createRequestFromPreview.ts +++ b/packages/anoncreds/src/utils/createRequestFromPreview.ts @@ -2,7 +2,7 @@ import type { AnonCredsPresentationPreviewAttribute, AnonCredsPresentationPreviewPredicate, } from '../formats/AnonCredsProofFormat' -import type { AnonCredsProofRequest } from '../models' +import type { AnonCredsNonRevokedInterval, AnonCredsProofRequest } from '../models' import { utils } from '@aries-framework/core' @@ -12,12 +12,14 @@ export function createRequestFromPreview({ nonce, attributes, predicates, + nonRevokedInterval, }: { name: string version: string nonce: string attributes: AnonCredsPresentationPreviewAttribute[] predicates: AnonCredsPresentationPreviewPredicate[] + nonRevokedInterval?: AnonCredsNonRevokedInterval }): AnonCredsProofRequest { const proofRequest: AnonCredsProofRequest = { name, @@ -85,5 +87,10 @@ export function createRequestFromPreview({ } } + // TODO: local non_revoked? + if (nonRevokedInterval) { + proofRequest.non_revoked = nonRevokedInterval + } + return proofRequest } diff --git a/packages/anoncreds/src/utils/getRevocationRegistries.ts b/packages/anoncreds/src/utils/getRevocationRegistries.ts index 0141bf257b..421fefe61a 100644 --- a/packages/anoncreds/src/utils/getRevocationRegistries.ts +++ b/packages/anoncreds/src/utils/getRevocationRegistries.ts @@ -4,10 +4,10 @@ import type { AgentContext } from '@aries-framework/core' import { AriesFrameworkError } from '@aries-framework/core' +import { AnonCredsModuleConfig } from '../AnonCredsModuleConfig' import { AnonCredsRegistryService } from '../services' import { assertBestPracticeRevocationInterval } from './revocationInterval' -import { downloadTailsFile } from './tails' export async function getRevocationRegistriesForRequest( agentContext: AgentContext, @@ -90,8 +90,10 @@ export async function getRevocationRegistriesForRequest( ) } - const { tailsLocation, tailsHash } = revocationRegistryDefinition.value - const { tailsFilePath } = await downloadTailsFile(agentContext, tailsLocation, tailsHash) + const tailsFileService = agentContext.dependencyManager.resolve(AnonCredsModuleConfig).tailsFileService + const tailsFilePath = await tailsFileService.getTailsFile(agentContext, { + revocationRegistryDefinition, + }) // const tails = await this.indyUtilitiesService.downloadTails(tailsHash, tailsLocation) revocationRegistries[revocationRegistryId] = { @@ -100,42 +102,41 @@ export async function getRevocationRegistriesForRequest( revocationStatusLists: {}, } } - } - - revocationRegistryPromises.push(getRevocationRegistry()) - // In most cases we will have a timestamp, but if it's not defined, we use the nonRevoked.to value - const timestampToFetch = timestamp ?? nonRevoked.to + // In most cases we will have a timestamp, but if it's not defined, we use the nonRevoked.to value + const timestampToFetch = timestamp ?? nonRevoked.to - // Fetch revocation status list if we don't already have a revocation status list for the given timestamp - if (!revocationRegistries[revocationRegistryId].revocationStatusLists[timestampToFetch]) { - const { revocationStatusList, resolutionMetadata: statusListResolutionMetadata } = - await registry.getRevocationStatusList(agentContext, revocationRegistryId, timestampToFetch) + // Fetch revocation status list if we don't already have a revocation status list for the given timestamp + if (!revocationRegistries[revocationRegistryId].revocationStatusLists[timestampToFetch]) { + const { revocationStatusList, resolutionMetadata: statusListResolutionMetadata } = + await registry.getRevocationStatusList(agentContext, revocationRegistryId, timestampToFetch) - if (!revocationStatusList) { - throw new AriesFrameworkError( - `Could not retrieve revocation status list for revocation registry ${revocationRegistryId}: ${statusListResolutionMetadata.message}` - ) - } + if (!revocationStatusList) { + throw new AriesFrameworkError( + `Could not retrieve revocation status list for revocation registry ${revocationRegistryId}: ${statusListResolutionMetadata.message}` + ) + } - revocationRegistries[revocationRegistryId].revocationStatusLists[revocationStatusList.timestamp] = - revocationStatusList - - // If we don't have a timestamp on the selected credential, we set it to the timestamp of the revocation status list - // this way we know which revocation status list to use when creating the proof. - if (!timestamp) { - updatedSelectedCredentials = { - ...updatedSelectedCredentials, - [type]: { - ...updatedSelectedCredentials[type], - [referent]: { - ...updatedSelectedCredentials[type][referent], - timestamp: revocationStatusList.timestamp, + revocationRegistries[revocationRegistryId].revocationStatusLists[revocationStatusList.timestamp] = + revocationStatusList + + // If we don't have a timestamp on the selected credential, we set it to the timestamp of the revocation status list + // this way we know which revocation status list to use when creating the proof. + if (!timestamp) { + updatedSelectedCredentials = { + ...updatedSelectedCredentials, + [type]: { + ...updatedSelectedCredentials[type], + [referent]: { + ...updatedSelectedCredentials[type][referent], + timestamp: revocationStatusList.timestamp, + }, }, - }, + } } } } + revocationRegistryPromises.push(getRevocationRegistry()) } } // await all revocation registry statuses asynchronously diff --git a/packages/anoncreds/src/utils/index.ts b/packages/anoncreds/src/utils/index.ts index d995623bb0..b49440268b 100644 --- a/packages/anoncreds/src/utils/index.ts +++ b/packages/anoncreds/src/utils/index.ts @@ -2,13 +2,13 @@ export { createRequestFromPreview } from './createRequestFromPreview' export { sortRequestedCredentialsMatches } from './sortRequestedCredentialsMatches' export { assertNoDuplicateGroupsNamesInProofRequest } from './hasDuplicateGroupNames' export { areAnonCredsProofRequestsEqual } from './areRequestsEqual' -export { downloadTailsFile } from './tails' export { assertBestPracticeRevocationInterval } from './revocationInterval' export { getRevocationRegistriesForRequest, getRevocationRegistriesForProof } from './getRevocationRegistries' export { encodeCredentialValue, checkValidCredentialValueEncoding } from './credential' export { IsMap } from './isMap' export { composeCredentialAutoAccept, composeProofAutoAccept } from './composeAutoAccept' export { areCredentialPreviewAttributesEqual } from './credentialPreviewAttributes' +export { dateToTimestamp } from './timestamp' export { storeLinkSecret } from './linkSecret' export { unqualifiedCredentialDefinitionIdRegex, diff --git a/packages/anoncreds/src/utils/tails.ts b/packages/anoncreds/src/utils/tails.ts deleted file mode 100644 index e706f00914..0000000000 --- a/packages/anoncreds/src/utils/tails.ts +++ /dev/null @@ -1,57 +0,0 @@ -import type { AgentContext, FileSystem } from '@aries-framework/core' - -import { TypedArrayEncoder, InjectionSymbols } from '@aries-framework/core' - -const getTailsFilePath = (cachePath: string, tailsHash: string) => `${cachePath}/anoncreds/tails/${tailsHash}` - -export function tailsFileExists(agentContext: AgentContext, tailsHash: string): Promise { - const fileSystem = agentContext.dependencyManager.resolve(InjectionSymbols.FileSystem) - const tailsFilePath = getTailsFilePath(fileSystem.cachePath, tailsHash) - - return fileSystem.exists(tailsFilePath) -} - -export async function downloadTailsFile( - agentContext: AgentContext, - tailsLocation: string, - tailsHashBase58: string -): Promise<{ - tailsFilePath: string -}> { - const fileSystem = agentContext.dependencyManager.resolve(InjectionSymbols.FileSystem) - - try { - agentContext.config.logger.debug( - `Checking to see if tails file for URL ${tailsLocation} has been stored in the FileSystem` - ) - - // hash is used as file identifier - const tailsExists = await tailsFileExists(agentContext, tailsHashBase58) - const tailsFilePath = getTailsFilePath(fileSystem.cachePath, tailsHashBase58) - agentContext.config.logger.debug( - `Tails file for ${tailsLocation} ${tailsExists ? 'is stored' : 'is not stored'} at ${tailsFilePath}` - ) - - if (!tailsExists) { - agentContext.config.logger.debug(`Retrieving tails file from URL ${tailsLocation}`) - - // download file and verify hash - await fileSystem.downloadToFile(tailsLocation, tailsFilePath, { - verifyHash: { - algorithm: 'sha256', - hash: TypedArrayEncoder.fromBase58(tailsHashBase58), - }, - }) - agentContext.config.logger.debug(`Saved tails file to FileSystem at path ${tailsFilePath}`) - } - - return { - tailsFilePath, - } - } catch (error) { - agentContext.config.logger.error(`Error while retrieving tails file from URL ${tailsLocation}`, { - error, - }) - throw error - } -} diff --git a/packages/anoncreds/src/utils/timestamp.ts b/packages/anoncreds/src/utils/timestamp.ts new file mode 100644 index 0000000000..9386fe68e3 --- /dev/null +++ b/packages/anoncreds/src/utils/timestamp.ts @@ -0,0 +1,2 @@ +// Timestamps are expressed as Unix epoch time (seconds since 1/1/1970) +export const dateToTimestamp = (date: Date) => Math.floor(date.getTime() / 1000) diff --git a/packages/anoncreds/tests/InMemoryAnonCredsRegistry.ts b/packages/anoncreds/tests/InMemoryAnonCredsRegistry.ts index 9cb3a9adf3..da51aa12ec 100644 --- a/packages/anoncreds/tests/InMemoryAnonCredsRegistry.ts +++ b/packages/anoncreds/tests/InMemoryAnonCredsRegistry.ts @@ -13,14 +13,30 @@ import type { AnonCredsRevocationRegistryDefinition, AnonCredsSchema, AnonCredsCredentialDefinition, + RegisterRevocationRegistryDefinitionOptions, + RegisterRevocationRegistryDefinitionReturn, + RegisterRevocationStatusListReturn, + RegisterRevocationStatusListOptions, } from '../src' import type { AgentContext } from '@aries-framework/core' import { Hasher, TypedArrayEncoder } from '@aries-framework/core' import BigNumber from 'bn.js' -import { getDidIndyCredentialDefinitionId, getDidIndySchemaId } from '../../indy-sdk/src/anoncreds/utils/identifiers' -import { getUnqualifiedCredentialDefinitionId, getUnqualifiedSchemaId, parseIndyDid, parseIndySchemaId } from '../src' +import { + getDidIndyCredentialDefinitionId, + getDidIndyRevocationRegistryId, + getDidIndySchemaId, +} from '../../indy-sdk/src/anoncreds/utils/identifiers' +import { + parseIndyCredentialDefinitionId, + getUnqualifiedRevocationRegistryId, + getUnqualifiedCredentialDefinitionId, + getUnqualifiedSchemaId, + parseIndyDid, + parseIndySchemaId, +} from '../src' +import { dateToTimestamp } from '../src/utils/timestamp' /** * In memory implementation of the {@link AnonCredsRegistry} interface. Useful for testing. @@ -219,6 +235,54 @@ export class InMemoryAnonCredsRegistry implements AnonCredsRegistry { } } + public async registerRevocationRegistryDefinition( + agentContext: AgentContext, + options: RegisterRevocationRegistryDefinitionOptions + ): Promise { + const parsedCredentialDefinition = parseIndyCredentialDefinitionId(options.revocationRegistryDefinition.credDefId) + const legacyCredentialDefinitionId = getUnqualifiedCredentialDefinitionId( + parsedCredentialDefinition.namespaceIdentifier, + parsedCredentialDefinition.schemaSeqNo, + parsedCredentialDefinition.tag + ) + const indyLedgerSeqNo = getSeqNoFromSchemaId(legacyCredentialDefinitionId) + + const { namespace, namespaceIdentifier } = parseIndyDid(options.revocationRegistryDefinition.issuerId) + const legacyIssuerId = namespaceIdentifier + const didIndyRevocationRegistryDefinitionId = getDidIndyRevocationRegistryId( + namespace, + namespaceIdentifier, + indyLedgerSeqNo, + parsedCredentialDefinition.tag, + options.revocationRegistryDefinition.tag + ) + + this.revocationRegistryDefinitions[didIndyRevocationRegistryDefinitionId] = options.revocationRegistryDefinition + + const legacyRevocationRegistryDefinitionId = getUnqualifiedRevocationRegistryId( + legacyIssuerId, + indyLedgerSeqNo, + parsedCredentialDefinition.tag, + options.revocationRegistryDefinition.tag + ) + + this.revocationRegistryDefinitions[legacyRevocationRegistryDefinitionId] = { + ...options.revocationRegistryDefinition, + issuerId: legacyIssuerId, + credDefId: legacyCredentialDefinitionId, + } + + return { + registrationMetadata: {}, + revocationRegistryDefinitionMetadata: {}, + revocationRegistryDefinitionState: { + state: 'finished', + revocationRegistryDefinition: options.revocationRegistryDefinition, + revocationRegistryDefinitionId: didIndyRevocationRegistryDefinitionId, + }, + } + } + public async getRevocationStatusList( agentContext: AgentContext, revocationRegistryId: string, @@ -226,7 +290,7 @@ export class InMemoryAnonCredsRegistry implements AnonCredsRegistry { ): Promise { const revocationStatusLists = this.revocationStatusLists[revocationRegistryId] - if (!revocationStatusLists || !revocationStatusLists[timestamp]) { + if (!revocationStatusLists || Object.entries(revocationStatusLists).length === 0) { return { resolutionMetadata: { error: 'notFound', @@ -236,12 +300,51 @@ export class InMemoryAnonCredsRegistry implements AnonCredsRegistry { } } + const previousTimestamps = Object.keys(revocationStatusLists) + .filter((ts) => Number(ts) <= timestamp) + .sort() + + if (!previousTimestamps) { + return { + resolutionMetadata: { + error: 'notFound', + message: `No active Revocation status list found at ${timestamp} for revocation registry with id ${revocationRegistryId}`, + }, + revocationStatusListMetadata: {}, + } + } + return { resolutionMetadata: {}, - revocationStatusList: revocationStatusLists[timestamp], + revocationStatusList: revocationStatusLists[previousTimestamps[previousTimestamps.length - 1]], revocationStatusListMetadata: {}, } } + + public async registerRevocationStatusList( + agentContext: AgentContext, + options: RegisterRevocationStatusListOptions + ): Promise { + const timestamp = (options.options.timestamp as number) ?? dateToTimestamp(new Date()) + const revocationStatusList = { + ...options.revocationStatusList, + timestamp, + } satisfies AnonCredsRevocationStatusList + if (!this.revocationStatusLists[options.revocationStatusList.revRegDefId]) { + this.revocationStatusLists[options.revocationStatusList.revRegDefId] = {} + } + + this.revocationStatusLists[revocationStatusList.revRegDefId][timestamp.toString()] = revocationStatusList + return { + registrationMetadata: {}, + revocationStatusListMetadata: {}, + revocationStatusListState: { + state: 'finished', + revocationStatusList, + timestamp: timestamp.toString(), + }, + } + } } /** diff --git a/packages/anoncreds/tests/anoncreds.test.ts b/packages/anoncreds/tests/anoncreds.test.ts index d56dc4b630..f96b116126 100644 --- a/packages/anoncreds/tests/anoncreds.test.ts +++ b/packages/anoncreds/tests/anoncreds.test.ts @@ -201,6 +201,7 @@ describe('AnonCreds API', () => { schemaId: '7Cd2Yj9yEZNcmNoH54tq9i:2:Test Schema:1.0.0', tag: 'TAG', }, + supportRevocation: false, options: {}, }) diff --git a/packages/anoncreds/tests/legacyAnonCredsSetup.ts b/packages/anoncreds/tests/legacyAnonCredsSetup.ts index 39e9b53c47..9709b509ea 100644 --- a/packages/anoncreds/tests/legacyAnonCredsSetup.ts +++ b/packages/anoncreds/tests/legacyAnonCredsSetup.ts @@ -511,10 +511,12 @@ async function registerSchema( async function registerCredentialDefinition( agent: AnonCredsTestsAgent, - credentialDefinition: AnonCredsRegisterCredentialDefinitionOptions + credentialDefinition: AnonCredsRegisterCredentialDefinitionOptions, + supportRevocation?: boolean ): Promise { const { credentialDefinitionState } = await agent.modules.anoncreds.registerCredentialDefinition({ credentialDefinition, + supportRevocation: supportRevocation ?? false, options: {}, }) diff --git a/packages/cheqd/src/anoncreds/services/CheqdAnonCredsRegistry.ts b/packages/cheqd/src/anoncreds/services/CheqdAnonCredsRegistry.ts index d9e27c18e2..391ce13d92 100644 --- a/packages/cheqd/src/anoncreds/services/CheqdAnonCredsRegistry.ts +++ b/packages/cheqd/src/anoncreds/services/CheqdAnonCredsRegistry.ts @@ -9,6 +9,8 @@ import type { RegisterCredentialDefinitionReturn, RegisterSchemaReturn, RegisterSchemaOptions, + RegisterRevocationRegistryDefinitionReturn, + RegisterRevocationStatusListReturn, } from '@aries-framework/anoncreds' import type { AgentContext } from '@aries-framework/core' @@ -290,6 +292,10 @@ export class CheqdAnonCredsRegistry implements AnonCredsRegistry { } } + public async registerRevocationRegistryDefinition(): Promise { + throw new Error('Not implemented!') + } + // FIXME: this method doesn't retrieve the revocation status list at a specified time, it just resolves the revocation registry definition public async getRevocationStatusList( agentContext: AgentContext, @@ -344,4 +350,8 @@ export class CheqdAnonCredsRegistry implements AnonCredsRegistry { } } } + + public async registerRevocationStatusList(): Promise { + throw new Error('Not implemented!') + } } diff --git a/packages/core/src/modules/credentials/CredentialsApi.ts b/packages/core/src/modules/credentials/CredentialsApi.ts index 4a0a6ac349..8cdcc0f1f5 100644 --- a/packages/core/src/modules/credentials/CredentialsApi.ts +++ b/packages/core/src/modules/credentials/CredentialsApi.ts @@ -15,6 +15,7 @@ import type { ProposeCredentialOptions, SendCredentialProblemReportOptions, DeleteCredentialOptions, + SendRevocationNotificationOptions, } from './CredentialsApiOptions' import type { CredentialProtocol } from './protocol/CredentialProtocol' import type { CredentialFormatsFromProtocols } from './protocol/CredentialProtocolOptions' @@ -60,6 +61,9 @@ export interface CredentialsApi { // Issue Credential Methods acceptCredential(options: AcceptCredentialOptions): Promise + // Revoke Credential Methods + sendRevocationNotification(options: SendRevocationNotificationOptions): Promise + // out of band createOffer(options: CreateCredentialOfferOptions): Promise<{ message: AgentMessage @@ -96,6 +100,7 @@ export class CredentialsApi implements Credent private credentialRepository: CredentialRepository private agentContext: AgentContext private didCommMessageRepository: DidCommMessageRepository + private revocationNotificationService: RevocationNotificationService private routingService: RoutingService private logger: Logger @@ -107,9 +112,7 @@ export class CredentialsApi implements Credent credentialRepository: CredentialRepository, mediationRecipientService: RoutingService, didCommMessageRepository: DidCommMessageRepository, - // only injected so the handlers will be registered - // eslint-disable-next-line @typescript-eslint/no-unused-vars - _revocationNotificationService: RevocationNotificationService, + revocationNotificationService: RevocationNotificationService, config: CredentialsModuleConfig ) { this.messageSender = messageSender @@ -118,6 +121,7 @@ export class CredentialsApi implements Credent this.routingService = mediationRecipientService this.agentContext = agentContext this.didCommMessageRepository = didCommMessageRepository + this.revocationNotificationService = revocationNotificationService this.logger = logger this.config = config } @@ -414,7 +418,7 @@ export class CredentialsApi implements Credent } const offerMessage = await protocol.findOfferMessage(this.agentContext, credentialRecord.id) if (!offerMessage) { - throw new AriesFrameworkError(`No offer message found for proof record with id '${credentialRecord.id}'`) + throw new AriesFrameworkError(`No offer message found for credential record with id '${credentialRecord.id}'`) } const { message } = await protocol.acceptRequest(this.agentContext, { @@ -485,6 +489,48 @@ export class CredentialsApi implements Credent return credentialRecord } + /** + * Send a revocation notification for a credential exchange record. Currently Revocation Notification V2 protocol is supported + * + * @param credentialRecordId The id of the credential record for which to send revocation notification + */ + public async sendRevocationNotification(options: SendRevocationNotificationOptions): Promise { + const { credentialRecordId, revocationId, revocationFormat, comment, requestAck } = options + + const credentialRecord = await this.getById(credentialRecordId) + + const { message } = await this.revocationNotificationService.v2CreateRevocationNotification({ + credentialId: revocationId, + revocationFormat, + comment, + requestAck, + }) + const protocol = this.getProtocol(credentialRecord.protocolVersion) + + const requestMessage = await protocol.findRequestMessage(this.agentContext, credentialRecord.id) + if (!requestMessage) { + throw new AriesFrameworkError(`No request message found for credential record with id '${credentialRecord.id}'`) + } + + const offerMessage = await protocol.findOfferMessage(this.agentContext, credentialRecord.id) + if (!offerMessage) { + throw new AriesFrameworkError(`No offer message found for credential record with id '${credentialRecord.id}'`) + } + + // Use connection if present + const connectionRecord = credentialRecord.connectionId + ? await this.connectionService.getById(this.agentContext, credentialRecord.connectionId) + : undefined + connectionRecord?.assertReady() + + const outboundMessageContext = await getOutboundMessageContext(this.agentContext, { + message, + connectionRecord, + associatedRecord: credentialRecord, + }) + await this.messageSender.sendMessage(outboundMessageContext) + } + /** * Send problem report message for a credential record * @param credentialRecordId The id of the credential record for which to send problem report diff --git a/packages/core/src/modules/credentials/CredentialsApiOptions.ts b/packages/core/src/modules/credentials/CredentialsApiOptions.ts index 19e9f17295..9f49b1ca98 100644 --- a/packages/core/src/modules/credentials/CredentialsApiOptions.ts +++ b/packages/core/src/modules/credentials/CredentialsApiOptions.ts @@ -121,6 +121,17 @@ export interface AcceptCredentialOptions { credentialRecordId: string } +/** + * Interface for CredentialsApi.sendRevocationNotification. Will send a revoke message + */ +export interface SendRevocationNotificationOptions { + credentialRecordId: string + revocationId: string // TODO: Get from record? + revocationFormat: string // TODO: Get from record? + comment?: string + requestAck?: boolean +} + /** * Interface for CredentialsApi.sendProblemReport. Will send a problem-report message */ diff --git a/packages/core/src/modules/credentials/protocol/revocation-notification/services/RevocationNotificationService.ts b/packages/core/src/modules/credentials/protocol/revocation-notification/services/RevocationNotificationService.ts index 5efc958bc8..87717ce986 100644 --- a/packages/core/src/modules/credentials/protocol/revocation-notification/services/RevocationNotificationService.ts +++ b/packages/core/src/modules/credentials/protocol/revocation-notification/services/RevocationNotificationService.ts @@ -1,9 +1,9 @@ +import type { V2CreateRevocationNotificationMessageOptions } from './RevocationNotificationServiceOptions' import type { AgentContext } from '../../../../../agent' import type { InboundMessageContext } from '../../../../../agent/models/InboundMessageContext' import type { ConnectionRecord } from '../../../../connections' import type { RevocationNotificationReceivedEvent } from '../../../CredentialEvents' import type { V1RevocationNotificationMessage } from '../messages/V1RevocationNotificationMessage' -import type { V2RevocationNotificationMessage } from '../messages/V2RevocationNotificationMessage' import { EventEmitter } from '../../../../../agent/EventEmitter' import { MessageHandlerRegistry } from '../../../../../agent/MessageHandlerRegistry' @@ -15,7 +15,14 @@ import { CredentialEventTypes } from '../../../CredentialEvents' import { RevocationNotification } from '../../../models/RevocationNotification' import { CredentialRepository } from '../../../repository' import { V1RevocationNotificationHandler, V2RevocationNotificationHandler } from '../handlers' -import { v1ThreadRegex, v2IndyRevocationFormat, v2IndyRevocationIdentifierRegex } from '../util/revocationIdentifier' +import { V2RevocationNotificationMessage } from '../messages/V2RevocationNotificationMessage' +import { + v1ThreadRegex, + v2AnonCredsRevocationFormat, + v2AnonCredsRevocationIdentifierRegex, + v2IndyRevocationFormat, + v2IndyRevocationIdentifierRegex, +} from '../util/revocationIdentifier' @injectable() export class RevocationNotificationService { @@ -100,6 +107,26 @@ export class RevocationNotificationService { } } + /** + * Create a V2 Revocation Notification message + */ + + public async v2CreateRevocationNotification( + options: V2CreateRevocationNotificationMessageOptions + ): Promise<{ message: V2RevocationNotificationMessage }> { + const { credentialId, revocationFormat, comment, requestAck } = options + const message = new V2RevocationNotificationMessage({ + credentialId, + revocationFormat, + comment, + }) + if (requestAck) { + message.setPleaseAck() + } + + return { message } + } + /** * Process a received {@link V2RevocationNotificationMessage}. This will create a * {@link RevocationNotification} and store it in the corresponding {@link CredentialRecord} @@ -113,14 +140,15 @@ export class RevocationNotificationService { const credentialId = messageContext.message.credentialId - if (messageContext.message.revocationFormat !== v2IndyRevocationFormat) { + if (![v2IndyRevocationFormat, v2AnonCredsRevocationFormat].includes(messageContext.message.revocationFormat)) { throw new AriesFrameworkError( - `Unknown revocation format: ${messageContext.message.revocationFormat}. Supported formats are indy-anoncreds` + `Unknown revocation format: ${messageContext.message.revocationFormat}. Supported formats are indy-anoncreds and anoncreds` ) } try { - const credentialIdGroups = credentialId.match(v2IndyRevocationIdentifierRegex) + const credentialIdGroups = + credentialId.match(v2IndyRevocationIdentifierRegex) ?? credentialId.match(v2AnonCredsRevocationIdentifierRegex) if (!credentialIdGroups) { throw new AriesFrameworkError( `Incorrect revocation notification credentialId format: \n${credentialId}\ndoes not match\n"::"` diff --git a/packages/core/src/modules/credentials/protocol/revocation-notification/services/RevocationNotificationServiceOptions.ts b/packages/core/src/modules/credentials/protocol/revocation-notification/services/RevocationNotificationServiceOptions.ts new file mode 100644 index 0000000000..406013b18b --- /dev/null +++ b/packages/core/src/modules/credentials/protocol/revocation-notification/services/RevocationNotificationServiceOptions.ts @@ -0,0 +1,6 @@ +export interface V2CreateRevocationNotificationMessageOptions { + credentialId: string + revocationFormat: string + comment?: string + requestAck?: boolean +} diff --git a/packages/core/src/modules/credentials/protocol/revocation-notification/services/index.ts b/packages/core/src/modules/credentials/protocol/revocation-notification/services/index.ts index 5413d4da87..e5696a92e1 100644 --- a/packages/core/src/modules/credentials/protocol/revocation-notification/services/index.ts +++ b/packages/core/src/modules/credentials/protocol/revocation-notification/services/index.ts @@ -1 +1,2 @@ export * from './RevocationNotificationService' +export * from './RevocationNotificationServiceOptions' diff --git a/packages/core/src/modules/credentials/protocol/revocation-notification/util/revocationIdentifier.ts b/packages/core/src/modules/credentials/protocol/revocation-notification/util/revocationIdentifier.ts index 12f75569d7..c1bc1d35f2 100644 --- a/packages/core/src/modules/credentials/protocol/revocation-notification/util/revocationIdentifier.ts +++ b/packages/core/src/modules/credentials/protocol/revocation-notification/util/revocationIdentifier.ts @@ -9,3 +9,8 @@ export const v2IndyRevocationIdentifierRegex = /((?:[\dA-z]{21,22}):4:(?:[\dA-z]{21,22}):3:[Cc][Ll]:(?:(?:[1-9][0-9]*)|(?:[\dA-z]{21,22}:2:.+:[0-9.]+)):.+?:CL_ACCUM:(?:[\dA-z-]+))::(\d+)$/ export const v2IndyRevocationFormat = 'indy-anoncreds' + +// CredentialID = :: +export const v2AnonCredsRevocationIdentifierRegex = /([a-zA-Z0-9+\-.]+:.+)::(\d+)$/ + +export const v2AnonCredsRevocationFormat = 'anoncreds' diff --git a/packages/core/tests/helpers.ts b/packages/core/tests/helpers.ts index 517c53a274..4ad2392ceb 100644 --- a/packages/core/tests/helpers.ts +++ b/packages/core/tests/helpers.ts @@ -14,6 +14,7 @@ import type { CredentialState, ConnectionStateChangedEvent, Buffer, + RevocationNotificationReceivedEvent, } from '../src' import type { AgentModulesInput, EmptyModuleMap } from '../src/agent/AgentModules' import type { TrustPingReceivedEvent, TrustPingResponseReceivedEvent } from '../src/modules/connections/TrustPingEvents' @@ -455,6 +456,47 @@ export async function waitForBasicMessage(agent: Agent, { content }: { content?: }) } +export async function waitForRevocationNotification( + agent: Agent, + options: { + threadId?: string + timeoutMs?: number + } +) { + const observable = agent.events.observable( + CredentialEventTypes.RevocationNotificationReceived + ) + + return waitForRevocationNotificationSubject(observable, options) +} + +export function waitForRevocationNotificationSubject( + subject: ReplaySubject | Observable, + { + threadId, + timeoutMs = 10000, + }: { + threadId?: string + timeoutMs?: number + } +) { + const observable = subject instanceof ReplaySubject ? subject.asObservable() : subject + return firstValueFrom( + observable.pipe( + filter((e) => threadId === undefined || e.payload.credentialRecord.threadId === threadId), + timeout(timeoutMs), + catchError(() => { + throw new Error( + `RevocationNotificationReceivedEvent event not emitted within specified timeout: { + threadId: ${threadId}, + }` + ) + }), + map((e) => e.payload.credentialRecord) + ) + ) +} + export function getMockConnection({ state = DidExchangeState.InvitationReceived, role = DidExchangeRole.Requester, diff --git a/packages/indy-sdk/src/anoncreds/services/IndySdkAnonCredsRegistry.ts b/packages/indy-sdk/src/anoncreds/services/IndySdkAnonCredsRegistry.ts index 21ab95ab53..a386675b1e 100644 --- a/packages/indy-sdk/src/anoncreds/services/IndySdkAnonCredsRegistry.ts +++ b/packages/indy-sdk/src/anoncreds/services/IndySdkAnonCredsRegistry.ts @@ -10,6 +10,8 @@ import type { RegisterCredentialDefinitionReturn, RegisterSchemaOptions, RegisterSchemaReturn, + RegisterRevocationRegistryDefinitionReturn, + RegisterRevocationStatusListReturn, } from '@aries-framework/anoncreds' import type { AgentContext } from '@aries-framework/core' import type { Schema as IndySdkSchema } from 'indy-sdk' @@ -23,6 +25,7 @@ import { parseIndyRevocationRegistryId, parseIndySchemaId, } from '@aries-framework/anoncreds' +import { AriesFrameworkError } from '@aries-framework/core' import { verificationKeyForIndyDid } from '../../dids/didIndyUtil' import { IndySdkError, isIndyError } from '../../error' @@ -468,6 +471,10 @@ export class IndySdkAnonCredsRegistry implements AnonCredsRegistry { } } + public async registerRevocationRegistryDefinition(): Promise { + throw new AriesFrameworkError('Not implemented!') + } + public async getRevocationStatusList( agentContext: AgentContext, revocationRegistryId: string, @@ -569,6 +576,10 @@ export class IndySdkAnonCredsRegistry implements AnonCredsRegistry { } } + public async registerRevocationStatusList(): Promise { + throw new AriesFrameworkError('Not implemented!') + } + private async fetchIndySchemaWithSeqNo(agentContext: AgentContext, pool: IndySdkPool, seqNo: number) { const indySdkPoolService = agentContext.dependencyManager.resolve(IndySdkPoolService) const indySdk = agentContext.dependencyManager.resolve(IndySdkSymbol) diff --git a/packages/indy-sdk/src/anoncreds/services/IndySdkIssuerService.ts b/packages/indy-sdk/src/anoncreds/services/IndySdkIssuerService.ts index 01973d31dd..b303bed598 100644 --- a/packages/indy-sdk/src/anoncreds/services/IndySdkIssuerService.ts +++ b/packages/indy-sdk/src/anoncreds/services/IndySdkIssuerService.ts @@ -9,6 +9,8 @@ import type { AnonCredsCredentialOffer, AnonCredsSchema, CreateCredentialDefinitionReturn, + CreateRevocationRegistryDefinitionReturn, + AnonCredsRevocationStatusList, } from '@aries-framework/anoncreds' import type { AgentContext } from '@aries-framework/core' @@ -24,7 +26,6 @@ import { assertUnqualifiedCredentialRequest, assertUnqualifiedRevocationRegistryId, } from '../utils/assertUnqualified' -import { createTailsReader } from '../utils/tails' import { indySdkSchemaFromAnonCreds } from '../utils/transform' @injectable() @@ -35,6 +36,18 @@ export class IndySdkIssuerService implements AnonCredsIssuerService { this.indySdk = indySdk } + public async createRevocationStatusList(): Promise { + throw new AriesFrameworkError('Method not implemented.') + } + + public async updateRevocationStatusList(): Promise { + throw new AriesFrameworkError('Method not implemented.') + } + + public async createRevocationRegistryDefinition(): Promise { + throw new AriesFrameworkError('Method not implemented.') + } + public async createSchema(agentContext: AgentContext, options: CreateSchemaOptions): Promise { // We only support passing qualified did:indy issuer ids in the indy issuer service when creating objects const { namespaceIdentifier } = parseIndyDid(options.issuerId) @@ -117,20 +130,23 @@ export class IndySdkIssuerService implements AnonCredsIssuerService { agentContext: AgentContext, options: CreateCredentialOptions ): Promise { - const { tailsFilePath, credentialOffer, credentialRequest, credentialValues, revocationRegistryId } = options + const { + revocationStatusList, + credentialOffer, + credentialRequest, + credentialValues, + revocationRegistryDefinitionId, + } = options assertIndySdkWallet(agentContext.wallet) assertUnqualifiedCredentialOffer(options.credentialOffer) assertUnqualifiedCredentialRequest(options.credentialRequest) - if (options.revocationRegistryId) { - assertUnqualifiedRevocationRegistryId(options.revocationRegistryId) + if (options.revocationRegistryDefinitionId) { + assertUnqualifiedRevocationRegistryId(options.revocationRegistryDefinitionId) } try { - // Indy SDK requires tailsReaderHandle. Use null if no tailsFilePath is present - const tailsReaderHandle = tailsFilePath ? await createTailsReader(agentContext, tailsFilePath) : 0 - - if (revocationRegistryId || tailsFilePath) { + if (revocationRegistryDefinitionId || revocationStatusList) { throw new AriesFrameworkError('Revocation not supported yet') } @@ -142,8 +158,8 @@ export class IndySdkIssuerService implements AnonCredsIssuerService { credentialOffer, { ...credentialRequest, prover_did: proverDid }, credentialValues, - revocationRegistryId ?? null, - tailsReaderHandle + revocationRegistryDefinitionId ?? null, + 0 ) return { diff --git a/packages/indy-vdr/src/anoncreds/IndyVdrAnonCredsRegistry.ts b/packages/indy-vdr/src/anoncreds/IndyVdrAnonCredsRegistry.ts index fbfaa49a0a..5251362983 100644 --- a/packages/indy-vdr/src/anoncreds/IndyVdrAnonCredsRegistry.ts +++ b/packages/indy-vdr/src/anoncreds/IndyVdrAnonCredsRegistry.ts @@ -7,6 +7,8 @@ import type { GetRevocationStatusListReturn, GetRevocationRegistryDefinitionReturn, AnonCredsRevocationRegistryDefinition, + RegisterRevocationRegistryDefinitionReturn, + RegisterRevocationStatusListReturn, AnonCredsSchema, AnonCredsCredentialDefinition, RegisterSchemaReturnStateFailed, @@ -30,6 +32,7 @@ import { parseIndyRevocationRegistryId, parseIndySchemaId, } from '@aries-framework/anoncreds' +import { AriesFrameworkError } from '@aries-framework/core' import { GetSchemaRequest, SchemaRequest, @@ -312,7 +315,7 @@ export class IndyVdrAnonCredsRegistry implements AnonCredsRegistry { const { schemaId, issuerId, tag, value } = credentialDefinition try { - // This will throw an error if trying to register a credential defintion with a legacy indy identifier. We only support did:indy + // This will throw an error if trying to register a credential definition with a legacy indy identifier. We only support did:indy // identifiers for registering, that will allow us to extract the namespace and means all stored records will use did:indy identifiers. const { namespaceIdentifier, namespace } = parseIndyDid(issuerId) const { endorserDid, endorserMode } = options.options @@ -549,6 +552,10 @@ export class IndyVdrAnonCredsRegistry implements AnonCredsRegistry { } } + public async registerRevocationRegistryDefinition(): Promise { + throw new AriesFrameworkError('Not implemented!') + } + public async getRevocationStatusList( agentContext: AgentContext, revocationRegistryId: string, @@ -653,6 +660,10 @@ export class IndyVdrAnonCredsRegistry implements AnonCredsRegistry { } } + public async registerRevocationStatusList(): Promise { + throw new AriesFrameworkError('Not implemented!') + } + private async fetchIndySchemaWithSeqNo(agentContext: AgentContext, seqNo: number, did: string) { const indyVdrPoolService = agentContext.dependencyManager.resolve(IndyVdrPoolService) diff --git a/samples/tails/.gitignore b/samples/tails/.gitignore new file mode 100644 index 0000000000..6c4291916e --- /dev/null +++ b/samples/tails/.gitignore @@ -0,0 +1 @@ +tails \ No newline at end of file diff --git a/samples/tails/FullTailsFileService.ts b/samples/tails/FullTailsFileService.ts new file mode 100644 index 0000000000..1ffa4c03c6 --- /dev/null +++ b/samples/tails/FullTailsFileService.ts @@ -0,0 +1,41 @@ +import type { AnonCredsRevocationRegistryDefinition } from '@aries-framework/anoncreds' +import type { AgentContext } from '@aries-framework/core' + +import { BasicTailsFileService } from '@aries-framework/anoncreds' +import { utils } from '@aries-framework/core' +import FormData from 'form-data' +import fs from 'fs' + +export class FullTailsFileService extends BasicTailsFileService { + private tailsServerBaseUrl?: string + public constructor(options?: { tailsDirectoryPath?: string; tailsServerBaseUrl?: string }) { + super(options) + this.tailsServerBaseUrl = options?.tailsServerBaseUrl + } + + public async uploadTailsFile( + agentContext: AgentContext, + options: { + revocationRegistryDefinition: AnonCredsRevocationRegistryDefinition + } + ): Promise { + const revocationRegistryDefinition = options.revocationRegistryDefinition + const localTailsFilePath = revocationRegistryDefinition.value.tailsLocation + + const tailsFileId = utils.uuid() + const data = new FormData() + const readStream = fs.createReadStream(localTailsFilePath) + data.append('file', readStream) + const response = await agentContext.config.agentDependencies.fetch( + `${this.tailsServerBaseUrl}/${encodeURIComponent(tailsFileId)}`, + { + method: 'PUT', + body: data, + } + ) + if (response.status !== 200) { + throw new Error('Cannot upload tails file') + } + return `${this.tailsServerBaseUrl}/${encodeURIComponent(tailsFileId)}` + } +} diff --git a/samples/tails/README.md b/samples/tails/README.md new file mode 100644 index 0000000000..838d207160 --- /dev/null +++ b/samples/tails/README.md @@ -0,0 +1,5 @@ +

Sample tails file server

+ +This is a very simple server that can be used to host AnonCreds tails files. It is intended to be used only for development purposes. + +It offers a single endpoint at the root that takes an URI-encoded `tailsFileId` as URL path and allows to upload (using PUT method and a through a multi-part encoded form) or retrieve a tails file (using GET method). diff --git a/samples/tails/package.json b/samples/tails/package.json new file mode 100644 index 0000000000..44cb263a53 --- /dev/null +++ b/samples/tails/package.json @@ -0,0 +1,27 @@ +{ + "name": "test-tails-file-server", + "version": "1.0.0", + "private": true, + "repository": { + "type": "git", + "url": "https://github.com/hyperledger/aries-framework-javascript", + "directory": "samples/tails/" + }, + "license": "Apache-2.0", + "scripts": { + "start": "ts-node server.ts" + }, + "devDependencies": { + "ts-node": "^10.4.0" + }, + "dependencies": { + "@aries-framework/anoncreds": "^0.4.0", + "@aries-framework/core": "^0.4.0", + "@types/express": "^4.17.13", + "@types/multer": "^1.4.7", + "@types/uuid": "^9.0.1", + "@types/ws": "^8.5.4", + "form-data": "^4.0.0", + "multer": "^1.4.5-lts.1" + } +} diff --git a/samples/tails/server.ts b/samples/tails/server.ts new file mode 100644 index 0000000000..b02d420d30 --- /dev/null +++ b/samples/tails/server.ts @@ -0,0 +1,132 @@ +/* eslint-disable @typescript-eslint/no-explicit-any */ +import { ConsoleLogger, LogLevel } from '@aries-framework/core' +import { createHash } from 'crypto' +import express from 'express' +import fs from 'fs' +import multer, { diskStorage } from 'multer' + +const port = process.env.AGENT_PORT ? Number(process.env.AGENT_PORT) : 3001 +const app = express() + +const baseFilePath = './tails' +const indexFilePath = `./${baseFilePath}/index.json` + +if (!fs.existsSync(baseFilePath)) { + fs.mkdirSync(baseFilePath, { recursive: true }) +} +const tailsIndex = ( + fs.existsSync(indexFilePath) ? JSON.parse(fs.readFileSync(indexFilePath, { encoding: 'utf-8' })) : {} +) as Record + +const logger = new ConsoleLogger(LogLevel.debug) + +function fileHash(filePath: string, algorithm = 'sha256') { + return new Promise((resolve, reject) => { + const shasum = createHash(algorithm) + try { + const s = fs.createReadStream(filePath) + s.on('data', function (data) { + shasum.update(data) + }) + // making digest + s.on('end', function () { + const hash = shasum.digest('hex') + return resolve(hash) + }) + } catch (error) { + return reject('error in calculation') + } + }) +} + +const fileStorage = diskStorage({ + filename: (req: any, file: { originalname: string }, cb: (arg0: null, arg1: string) => void) => { + cb(null, file.originalname + '-' + new Date().toISOString()) + }, +}) + +// Allow to create invitation, no other way to ask for invitation yet +app.get('/:tailsFileId', async (req, res) => { + logger.debug(`requested file`) + + const tailsFileId = req.params.tailsFileId + if (!tailsFileId) { + res.status(409).end() + return + } + + const fileName = tailsIndex[tailsFileId] + + if (!fileName) { + logger.debug(`no entry found for tailsFileId: ${tailsFileId}`) + res.status(404).end() + return + } + + const path = `${baseFilePath}/${fileName}` + try { + logger.debug(`reading file: ${path}`) + + if (!fs.existsSync(path)) { + logger.debug(`file not found: ${path}`) + res.status(404).end() + return + } + + const file = fs.createReadStream(path) + res.setHeader('Content-Disposition', `attachment: filename="${fileName}"`) + file.pipe(res) + } catch (error) { + logger.debug(`error reading file: ${path}`) + res.status(500).end() + } +}) + +app.put('/:tailsFileId', multer({ storage: fileStorage }).single('file'), async (req, res) => { + logger.info(`tails file upload: ${req.params.tailsFileId}`) + + const file = req.file + + if (!file) { + logger.info(`No file found: ${JSON.stringify(req.headers)}`) + return res.status(400).send('No files were uploaded.') + } + + const tailsFileId = req.params.tailsFileId + if (!tailsFileId) { + // Clean up temporary file + fs.rmSync(file.path) + return res.status(409).send('Missing tailsFileId') + } + + const item = tailsIndex[tailsFileId] + + if (item) { + logger.debug(`there is already an entry for: ${tailsFileId}`) + res.status(409).end() + return + } + + const hash = await fileHash(file.path) + const destinationPath = `${baseFilePath}/${hash}` + + if (fs.existsSync(destinationPath)) { + logger.warn('tails file already exists') + } else { + fs.copyFileSync(file.path, destinationPath) + fs.rmSync(file.path) + } + + // Store filename in index + tailsIndex[tailsFileId] = hash + fs.writeFileSync(indexFilePath, JSON.stringify(tailsIndex)) + + res.status(200).end() +}) + +const run = async () => { + app.listen(port) + logger.info(`server started at port ${port}`) +} + +void run() diff --git a/samples/tails/tsconfig.json b/samples/tails/tsconfig.json new file mode 100644 index 0000000000..46efe6f721 --- /dev/null +++ b/samples/tails/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["jest"] + } +} diff --git a/samples/tails/yarn.lock b/samples/tails/yarn.lock new file mode 100644 index 0000000000..bf3778cf7f --- /dev/null +++ b/samples/tails/yarn.lock @@ -0,0 +1,335 @@ +# THIS IS AN AUTOGENERATED FILE. DO NOT EDIT THIS FILE DIRECTLY. +# yarn lockfile v1 + + +"@cspotcode/source-map-support@^0.8.0": + version "0.8.1" + resolved "https://registry.yarnpkg.com/@cspotcode/source-map-support/-/source-map-support-0.8.1.tgz#00629c35a688e05a88b1cda684fb9d5e73f000a1" + integrity sha512-IchNf6dN4tHoMFIn/7OE8LWZ19Y6q/67Bmf6vnGREv8RSbBVb9LPJxEcnwrcwX6ixSvaiGoomAUvu4YSxXrVgw== + dependencies: + "@jridgewell/trace-mapping" "0.3.9" + +"@jridgewell/resolve-uri@^3.0.3": + version "3.1.0" + resolved "https://registry.yarnpkg.com/@jridgewell/resolve-uri/-/resolve-uri-3.1.0.tgz#2203b118c157721addfe69d47b70465463066d78" + integrity sha512-F2msla3tad+Mfht5cJq7LSXcdudKTWCVYUgw6pLFOOHSTtZlj6SWNYAp+AhuqLmWdBO2X5hPrLcu8cVP8fy28w== + +"@jridgewell/sourcemap-codec@^1.4.10": + version "1.4.14" + resolved "https://registry.yarnpkg.com/@jridgewell/sourcemap-codec/-/sourcemap-codec-1.4.14.tgz#add4c98d341472a289190b424efbdb096991bb24" + integrity sha512-XPSJHWmi394fuUuzDnGz1wiKqWfo1yXecHQMRf2l6hztTO+nPru658AyDngaBe7isIxEkRsPR3FZh+s7iVa4Uw== + +"@jridgewell/trace-mapping@0.3.9": + version "0.3.9" + resolved "https://registry.yarnpkg.com/@jridgewell/trace-mapping/-/trace-mapping-0.3.9.tgz#6534fd5933a53ba7cbf3a17615e273a0d1273ff9" + integrity sha512-3Belt6tdc8bPgAtbcmdtNJlirVoTmEb5e2gC94PnkwEW9jI6CAHUeoG85tjWP5WquqfavoMtMwiG4P926ZKKuQ== + dependencies: + "@jridgewell/resolve-uri" "^3.0.3" + "@jridgewell/sourcemap-codec" "^1.4.10" + +"@tsconfig/node10@^1.0.7": + version "1.0.9" + resolved "https://registry.yarnpkg.com/@tsconfig/node10/-/node10-1.0.9.tgz#df4907fc07a886922637b15e02d4cebc4c0021b2" + integrity sha512-jNsYVVxU8v5g43Erja32laIDHXeoNvFEpX33OK4d6hljo3jDhCBDhx5dhCCTMWUojscpAagGiRkBKxpdl9fxqA== + +"@tsconfig/node12@^1.0.7": + version "1.0.11" + resolved "https://registry.yarnpkg.com/@tsconfig/node12/-/node12-1.0.11.tgz#ee3def1f27d9ed66dac6e46a295cffb0152e058d" + integrity sha512-cqefuRsh12pWyGsIoBKJA9luFu3mRxCA+ORZvA4ktLSzIuCUtWVxGIuXigEwO5/ywWFMZ2QEGKWvkZG1zDMTag== + +"@tsconfig/node14@^1.0.0": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node14/-/node14-1.0.3.tgz#e4386316284f00b98435bf40f72f75a09dabf6c1" + integrity sha512-ysT8mhdixWK6Hw3i1V2AeRqZ5WfXg1G43mqoYlM2nc6388Fq5jcXyr5mRsqViLx/GJYdoL0bfXD8nmF+Zn/Iow== + +"@tsconfig/node16@^1.0.2": + version "1.0.3" + resolved "https://registry.yarnpkg.com/@tsconfig/node16/-/node16-1.0.3.tgz#472eaab5f15c1ffdd7f8628bd4c4f753995ec79e" + integrity sha512-yOlFc+7UtL/89t2ZhjPvvB/DeAr3r+Dq58IgzsFkOAvVC6NMJXmCGjbptdXdR9qsX7pKcTL+s87FtYREi2dEEQ== + +"@types/body-parser@*": + version "1.19.2" + resolved "https://registry.yarnpkg.com/@types/body-parser/-/body-parser-1.19.2.tgz#aea2059e28b7658639081347ac4fab3de166e6f0" + integrity sha512-ALYone6pm6QmwZoAgeyNksccT9Q4AWZQ6PvfwR37GT6r6FWUPguq6sUmNGSMV2Wr761oQoBxwGGa6DR5o1DC9g== + dependencies: + "@types/connect" "*" + "@types/node" "*" + +"@types/connect@*": + version "3.4.35" + resolved "https://registry.yarnpkg.com/@types/connect/-/connect-3.4.35.tgz#5fcf6ae445e4021d1fc2219a4873cc73a3bb2ad1" + integrity sha512-cdeYyv4KWoEgpBISTxWvqYsVy444DOqehiF3fM3ne10AmJ62RSyNkUnxMJXHQWRQQX2eR94m5y1IZyDwBjV9FQ== + dependencies: + "@types/node" "*" + +"@types/express-serve-static-core@^4.17.33": + version "4.17.33" + resolved "https://registry.yarnpkg.com/@types/express-serve-static-core/-/express-serve-static-core-4.17.33.tgz#de35d30a9d637dc1450ad18dd583d75d5733d543" + integrity sha512-TPBqmR/HRYI3eC2E5hmiivIzv+bidAfXofM+sbonAGvyDhySGw9/PQZFt2BLOrjUUR++4eJVpx6KnLQK1Fk9tA== + dependencies: + "@types/node" "*" + "@types/qs" "*" + "@types/range-parser" "*" + +"@types/express@*", "@types/express@^4.17.13": + version "4.17.17" + resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.17.tgz#01d5437f6ef9cfa8668e616e13c2f2ac9a491ae4" + integrity sha512-Q4FmmuLGBG58btUnfS1c1r/NQdlp3DMfGDGig8WhfpA2YRUtEkxAjkZb0yvplJGYdF1fsQ81iMDcH24sSCNC/Q== + dependencies: + "@types/body-parser" "*" + "@types/express-serve-static-core" "^4.17.33" + "@types/qs" "*" + "@types/serve-static" "*" + +"@types/mime@*": + version "3.0.1" + resolved "https://registry.yarnpkg.com/@types/mime/-/mime-3.0.1.tgz#5f8f2bca0a5863cb69bc0b0acd88c96cb1d4ae10" + integrity sha512-Y4XFY5VJAuw0FgAqPNd6NNoV44jbq9Bz2L7Rh/J6jLTiHBSBJa9fxqQIvkIld4GsoDOcCbvzOUAbLPsSKKg+uA== + +"@types/multer@^1.4.7": + version "1.4.7" + resolved "https://registry.yarnpkg.com/@types/multer/-/multer-1.4.7.tgz#89cf03547c28c7bbcc726f029e2a76a7232cc79e" + integrity sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA== + dependencies: + "@types/express" "*" + +"@types/node@*": + version "18.15.11" + resolved "https://registry.yarnpkg.com/@types/node/-/node-18.15.11.tgz#b3b790f09cb1696cffcec605de025b088fa4225f" + integrity sha512-E5Kwq2n4SbMzQOn6wnmBjuK9ouqlURrcZDVfbo9ftDDTFt3nk7ZKK4GMOzoYgnpQJKcxwQw+lGaBvvlMo0qN/Q== + +"@types/qs@*": + version "6.9.7" + resolved "https://registry.yarnpkg.com/@types/qs/-/qs-6.9.7.tgz#63bb7d067db107cc1e457c303bc25d511febf6cb" + integrity sha512-FGa1F62FT09qcrueBA6qYTrJPVDzah9a+493+o2PCXsesWHIn27G98TsSMs3WPNbZIEj4+VJf6saSFpvD+3Zsw== + +"@types/range-parser@*": + version "1.2.4" + resolved "https://registry.yarnpkg.com/@types/range-parser/-/range-parser-1.2.4.tgz#cd667bcfdd025213aafb7ca5915a932590acdcdc" + integrity sha512-EEhsLsD6UsDM1yFhAvy0Cjr6VwmpMWqFBCb9w07wVugF7w9nfajxLuVmngTIpgS6svCnm6Vaw+MZhoDCKnOfsw== + +"@types/serve-static@*": + version "1.15.1" + resolved "https://registry.yarnpkg.com/@types/serve-static/-/serve-static-1.15.1.tgz#86b1753f0be4f9a1bee68d459fcda5be4ea52b5d" + integrity sha512-NUo5XNiAdULrJENtJXZZ3fHtfMolzZwczzBbnAeBbqBwG+LaG6YaJtuwzwGSQZ2wsCrxjEhNNjAkKigy3n8teQ== + dependencies: + "@types/mime" "*" + "@types/node" "*" + +"@types/uuid@^9.0.1": + version "9.0.1" + resolved "https://registry.yarnpkg.com/@types/uuid/-/uuid-9.0.1.tgz#98586dc36aee8dacc98cc396dbca8d0429647aa6" + integrity sha512-rFT3ak0/2trgvp4yYZo5iKFEPsET7vKydKF+VRCxlQ9bpheehyAJH89dAkaLEq/j/RZXJIqcgsmPJKUP1Z28HA== + +"@types/ws@^8.5.4": + version "8.5.4" + resolved "https://registry.yarnpkg.com/@types/ws/-/ws-8.5.4.tgz#bb10e36116d6e570dd943735f86c933c1587b8a5" + integrity sha512-zdQDHKUgcX/zBc4GrwsE/7dVdAD8JR4EuiAXiiUhhfyIJXXb2+PrGshFyeXWQPMmmZ2XxgaqclgpIC7eTXc1mg== + dependencies: + "@types/node" "*" + +acorn-walk@^8.1.1: + version "8.2.0" + resolved "https://registry.yarnpkg.com/acorn-walk/-/acorn-walk-8.2.0.tgz#741210f2e2426454508853a2f44d0ab83b7f69c1" + integrity sha512-k+iyHEuPgSw6SbuDpGQM+06HQUa04DZ3o+F6CSzXMvvI5KMvnaEqXe+YVe555R9nn6GPt404fos4wcgpw12SDA== + +acorn@^8.4.1: + version "8.8.2" + resolved "https://registry.yarnpkg.com/acorn/-/acorn-8.8.2.tgz#1b2f25db02af965399b9776b0c2c391276d37c4a" + integrity sha512-xjIYgE8HBrkpd/sJqOGNspf8uHG+NOHGOw6a/Urj8taM2EXfdNAH2oFcPeIFfsv3+kz/mJrS5VuMqbNLjCa2vw== + +append-field@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56" + integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw== + +arg@^4.1.0: + version "4.1.3" + resolved "https://registry.yarnpkg.com/arg/-/arg-4.1.3.tgz#269fc7ad5b8e42cb63c896d5666017261c144089" + integrity sha512-58S9QDqG0Xx27YwPSt9fJxivjYl432YCwfDMfZ+71RAqUrZef7LrKQZ3LHLOwCS4FLNBplP533Zx895SeOCHvA== + +buffer-from@^1.0.0: + version "1.1.2" + resolved "https://registry.yarnpkg.com/buffer-from/-/buffer-from-1.1.2.tgz#2b146a6fd72e80b4f55d255f35ed59a3a9a41bd5" + integrity sha512-E+XQCRwSbaaiChtv6k6Dwgc+bx+Bs6vuKJHHl5kox/BaKbhiXzqQOwK4cO22yElGp2OCmjwVhT3HmxgyPGnJfQ== + +busboy@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + +concat-stream@^1.5.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + +core-util-is@~1.0.0: + version "1.0.3" + resolved "https://registry.yarnpkg.com/core-util-is/-/core-util-is-1.0.3.tgz#a6042d3634c2b27e9328f837b965fac83808db85" + integrity sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ== + +create-require@^1.1.0: + version "1.1.1" + resolved "https://registry.yarnpkg.com/create-require/-/create-require-1.1.1.tgz#c1d7e8f1e5f6cfc9ff65f9cd352d37348756c333" + integrity sha512-dcKFX3jn0MpIaXjisoRvexIJVEKzaq7z2rZKxf+MSr9TkdmHmsU4m2lcLojrj/FHl8mk5VxMmYA+ftRkP/3oKQ== + +diff@^4.0.1: + version "4.0.2" + resolved "https://registry.yarnpkg.com/diff/-/diff-4.0.2.tgz#60f3aecb89d5fae520c11aa19efc2bb982aade7d" + integrity sha512-58lmxKSA4BNyLz+HHMUzlOEpg09FV+ev6ZMe3vJihgdxzgcwZ8VoEEPmALCZG9LmqfVoNMMKpttIYTVG6uDY7A== + +inherits@^2.0.3, inherits@~2.0.3: + version "2.0.4" + resolved "https://registry.yarnpkg.com/inherits/-/inherits-2.0.4.tgz#0fa2c64f932917c3433a0ded55363aae37416b7c" + integrity sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ== + +isarray@~1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/isarray/-/isarray-1.0.0.tgz#bb935d48582cba168c06834957a54a3e07124f11" + integrity sha512-VLghIWNM6ELQzo7zwmcg0NmTVyWKYjvIeM83yjp0wRDTmUnrM678fQbcKBo6n2CJEF0szoG//ytg+TKla89ALQ== + +make-error@^1.1.1: + version "1.3.6" + resolved "https://registry.yarnpkg.com/make-error/-/make-error-1.3.6.tgz#2eb2e37ea9b67c4891f684a1394799af484cf7a2" + integrity sha512-s8UhlNe7vPKomQhC1qFelMokr/Sc3AgNbso3n74mVPA5LTZwkB9NlXf4XPamLxJE8h0gh73rM94xvwRT2CVInw== + +media-typer@0.3.0: + version "0.3.0" + resolved "https://registry.yarnpkg.com/media-typer/-/media-typer-0.3.0.tgz#8710d7af0aa626f8fffa1ce00168545263255748" + integrity sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ== + +mime-db@1.52.0: + version "1.52.0" + resolved "https://registry.yarnpkg.com/mime-db/-/mime-db-1.52.0.tgz#bbabcdc02859f4987301c856e3387ce5ec43bf70" + integrity sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg== + +mime-types@~2.1.24: + version "2.1.35" + resolved "https://registry.yarnpkg.com/mime-types/-/mime-types-2.1.35.tgz#381a871b62a734450660ae3deee44813f70d959a" + integrity sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw== + dependencies: + mime-db "1.52.0" + +minimist@^1.2.6: + version "1.2.8" + resolved "https://registry.yarnpkg.com/minimist/-/minimist-1.2.8.tgz#c1a464e7693302e082a075cee0c057741ac4772c" + integrity sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA== + +mkdirp@^0.5.4: + version "0.5.6" + resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" + integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== + dependencies: + minimist "^1.2.6" + +multer@^1.4.5-lts.1: + version "1.4.5-lts.1" + resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.5-lts.1.tgz#803e24ad1984f58edffbc79f56e305aec5cfd1ac" + integrity sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ== + dependencies: + append-field "^1.0.0" + busboy "^1.0.0" + concat-stream "^1.5.2" + mkdirp "^0.5.4" + object-assign "^4.1.1" + type-is "^1.6.4" + xtend "^4.0.0" + +object-assign@^4.1.1: + version "4.1.1" + resolved "https://registry.yarnpkg.com/object-assign/-/object-assign-4.1.1.tgz#2109adc7965887cfc05cbbd442cac8bfbb360863" + integrity sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg== + +process-nextick-args@~2.0.0: + version "2.0.1" + resolved "https://registry.yarnpkg.com/process-nextick-args/-/process-nextick-args-2.0.1.tgz#7820d9b16120cc55ca9ae7792680ae7dba6d7fe2" + integrity sha512-3ouUOpQhtgrbOa17J7+uxOTpITYWaGP7/AhoR3+A+/1e9skrzelGi/dXzEYyvbxubEF6Wn2ypscTKiKJFFn1ag== + +readable-stream@^2.2.2: + version "2.3.8" + resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" + integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== + dependencies: + core-util-is "~1.0.0" + inherits "~2.0.3" + isarray "~1.0.0" + process-nextick-args "~2.0.0" + safe-buffer "~5.1.1" + string_decoder "~1.1.1" + util-deprecate "~1.0.1" + +safe-buffer@~5.1.0, safe-buffer@~5.1.1: + version "5.1.2" + resolved "https://registry.yarnpkg.com/safe-buffer/-/safe-buffer-5.1.2.tgz#991ec69d296e0313747d59bdfd2b745c35f8828d" + integrity sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g== + +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + +string_decoder@~1.1.1: + version "1.1.1" + resolved "https://registry.yarnpkg.com/string_decoder/-/string_decoder-1.1.1.tgz#9cf1611ba62685d7030ae9e4ba34149c3af03fc8" + integrity sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg== + dependencies: + safe-buffer "~5.1.0" + +ts-node@^10.4.0: + version "10.9.1" + resolved "https://registry.yarnpkg.com/ts-node/-/ts-node-10.9.1.tgz#e73de9102958af9e1f0b168a6ff320e25adcff4b" + integrity sha512-NtVysVPkxxrwFGUUxGYhfux8k78pQB3JqYBXlLRZgdGUqTO5wU/UyHop5p70iEbGhB7q5KmiZiU0Y3KlJrScEw== + dependencies: + "@cspotcode/source-map-support" "^0.8.0" + "@tsconfig/node10" "^1.0.7" + "@tsconfig/node12" "^1.0.7" + "@tsconfig/node14" "^1.0.0" + "@tsconfig/node16" "^1.0.2" + acorn "^8.4.1" + acorn-walk "^8.1.1" + arg "^4.1.0" + create-require "^1.1.0" + diff "^4.0.1" + make-error "^1.1.1" + v8-compile-cache-lib "^3.0.1" + yn "3.1.1" + +type-is@^1.6.4: + version "1.6.18" + resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" + integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== + dependencies: + media-typer "0.3.0" + mime-types "~2.1.24" + +typedarray@^0.0.6: + version "0.0.6" + resolved "https://registry.yarnpkg.com/typedarray/-/typedarray-0.0.6.tgz#867ac74e3864187b1d3d47d996a78ec5c8830777" + integrity sha512-/aCDEGatGvZ2BIk+HmLf4ifCJFwvKFNb9/JeZPMulfgFracn9QFcAf5GO8B/mweUjSoblS5In0cWhqpfs/5PQA== + +util-deprecate@~1.0.1: + version "1.0.2" + resolved "https://registry.yarnpkg.com/util-deprecate/-/util-deprecate-1.0.2.tgz#450d4dc9fa70de732762fbd2d4a28981419a0ccf" + integrity sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw== + +v8-compile-cache-lib@^3.0.1: + version "3.0.1" + resolved "https://registry.yarnpkg.com/v8-compile-cache-lib/-/v8-compile-cache-lib-3.0.1.tgz#6336e8d71965cb3d35a1bbb7868445a7c05264bf" + integrity sha512-wa7YjyUGfNZngI/vtK0UHAN+lgDCxBPCylVXGp0zu59Fz5aiGtNXaq3DhIov063MorB+VfufLh3JlF2KdTK3xg== + +xtend@^4.0.0: + version "4.0.2" + resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" + integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ== + +yn@3.1.1: + version "3.1.1" + resolved "https://registry.yarnpkg.com/yn/-/yn-3.1.1.tgz#1e87401a09d767c1d5eab26a6e4c185182d2eb50" + integrity sha512-Ux4ygGWsu2c7isFWe8Yu1YluJmqVhxqK2cLXNQA5AcC3QfbGNpM7fu0Y8b/z16pXLnFxZYvWhd3fhBY9DLmC6Q== diff --git a/yarn.lock b/yarn.lock index 2afc180e9f..abae25902c 100644 --- a/yarn.lock +++ b/yarn.lock @@ -2855,7 +2855,7 @@ "@types/qs" "*" "@types/range-parser" "*" -"@types/express@^4.17.13", "@types/express@^4.17.15": +"@types/express@*", "@types/express@^4.17.13", "@types/express@^4.17.15": version "4.17.18" resolved "https://registry.yarnpkg.com/@types/express/-/express-4.17.18.tgz#efabf5c4495c1880df1bdffee604b143b29c4a95" integrity sha512-Sxv8BSLLgsBYmcnGdGjjEjqET2U+AKAdCRODmMiq02FgjwuV75Ut85DRpvFjyw/Mk0vgUOliGRU0UUmuuZHByQ== @@ -2954,6 +2954,13 @@ resolved "https://registry.yarnpkg.com/@types/minimist/-/minimist-1.2.2.tgz#ee771e2ba4b3dc5b372935d549fd9617bf345b8c" integrity sha512-jhuKLIRrhvCPLqwPcx6INqmKeiA5EWrsCOPhrlFSrbrmU4ZMPjj5Ul/oLCMDO98XRUIwVm78xICz4EPCektzeQ== +"@types/multer@^1.4.7": + version "1.4.7" + resolved "https://registry.yarnpkg.com/@types/multer/-/multer-1.4.7.tgz#89cf03547c28c7bbcc726f029e2a76a7232cc79e" + integrity sha512-/SNsDidUFCvqqcWDwxv2feww/yqhNeTRL5CVoL3jU4Goc4kKEL10T7Eye65ZqPNi4HRx8sAEX59pV1aEH7drNA== + dependencies: + "@types/express" "*" + "@types/node@*", "@types/node@18.18.8", "@types/node@>=13.7.0", "@types/node@^18.18.8": version "18.18.8" resolved "https://registry.yarnpkg.com/@types/node/-/node-18.18.8.tgz#2b285361f2357c8c8578ec86b5d097c7f464cfd6" @@ -3366,6 +3373,11 @@ appdirsjs@^1.2.4: resolved "https://registry.yarnpkg.com/appdirsjs/-/appdirsjs-1.2.7.tgz#50b4b7948a26ba6090d4aede2ae2dc2b051be3b3" integrity sha512-Quji6+8kLBC3NnBeo14nPDq0+2jUs5s3/xEye+udFHumHhRk4M7aAMXp/PBJqkKYGuuyR9M/6Dq7d2AViiGmhw== +append-field@^1.0.0: + version "1.0.0" + resolved "https://registry.yarnpkg.com/append-field/-/append-field-1.0.0.tgz#1e3440e915f0b1203d23748e78edd7b9b5b43e56" + integrity sha512-klpgFSWLW1ZEs8svjfb7g4qWY0YS5imI82dTg+QahUvJ8YqAY0P10Uk8tTyh9ZGuYEZEMaeJYCF5BFuX552hsw== + aproba@^1.0.3: version "1.2.0" resolved "https://registry.yarnpkg.com/aproba/-/aproba-1.2.0.tgz#6802e6264efd18c790a1b0d517f0f2627bf2c94a" @@ -3996,6 +4008,13 @@ builtins@^5.0.0: dependencies: semver "^7.0.0" +busboy@^1.0.0: + version "1.6.0" + resolved "https://registry.yarnpkg.com/busboy/-/busboy-1.6.0.tgz#966ea36a9502e43cdb9146962523b92f531f6893" + integrity sha512-8SFQbg/0hQ9xy3UNTB0YEnsNBbWfhf7RtnzpL7TkBiTBRfrQ9Fxcnz7VJsleJpyp6rVLvXiuORqjlHi5q+PYuA== + dependencies: + streamsearch "^1.1.0" + byte-size@7.0.0: version "7.0.0" resolved "https://registry.yarnpkg.com/byte-size/-/byte-size-7.0.0.tgz#36528cd1ca87d39bd9abd51f5715dc93b6ceb032" @@ -4510,6 +4529,16 @@ concat-map@0.0.1: resolved "https://registry.yarnpkg.com/concat-map/-/concat-map-0.0.1.tgz#d8a96bd77fd68df7793a73036a3ba0d5405d477b" integrity sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg== +concat-stream@^1.5.2: + version "1.6.2" + resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-1.6.2.tgz#904bdf194cd3122fc675c77fc4ac3d4ff0fd1a34" + integrity sha512-27HBghJxjiZtIk3Ycvn/4kbJk/1uZuJFfuPEns6LaEvpvG1f0hTea8lilrouyo9mVc2GWdcEZ8OLoGmSADlrCw== + dependencies: + buffer-from "^1.0.0" + inherits "^2.0.3" + readable-stream "^2.2.2" + typedarray "^0.0.6" + concat-stream@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/concat-stream/-/concat-stream-2.0.0.tgz#414cf5af790a48c60ab9be4527d56d5e41133cb1" @@ -9005,7 +9034,7 @@ mkdirp-infer-owner@^2.0.0: infer-owner "^1.0.4" mkdirp "^1.0.3" -mkdirp@^0.5.1, mkdirp@^0.5.5: +mkdirp@^0.5.1, mkdirp@^0.5.4, mkdirp@^0.5.5: version "0.5.6" resolved "https://registry.yarnpkg.com/mkdirp/-/mkdirp-0.5.6.tgz#7def03d2432dcae4ba1d611445c48396062255f6" integrity sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw== @@ -9042,6 +9071,19 @@ msrcrypto@^1.5.6: resolved "https://registry.yarnpkg.com/msrcrypto/-/msrcrypto-1.5.8.tgz#be419be4945bf134d8af52e9d43be7fa261f4a1c" integrity sha512-ujZ0TRuozHKKm6eGbKHfXef7f+esIhEckmThVnz7RNyiOJd7a6MXj2JGBoL9cnPDW+JMG16MoTUh5X+XXjI66Q== +multer@^1.4.5-lts.1: + version "1.4.5-lts.1" + resolved "https://registry.yarnpkg.com/multer/-/multer-1.4.5-lts.1.tgz#803e24ad1984f58edffbc79f56e305aec5cfd1ac" + integrity sha512-ywPWvcDMeH+z9gQq5qYHCCy+ethsk4goepZ45GLD63fOu0YcNecQxi64nDs3qluZB+murG3/D4dJ7+dGctcCQQ== + dependencies: + append-field "^1.0.0" + busboy "^1.0.0" + concat-stream "^1.5.2" + mkdirp "^0.5.4" + object-assign "^4.1.1" + type-is "^1.6.4" + xtend "^4.0.0" + multiformats@^9.4.2, multiformats@^9.6.5, multiformats@^9.9.0: version "9.9.0" resolved "https://registry.yarnpkg.com/multiformats/-/multiformats-9.9.0.tgz#c68354e7d21037a8f1f8833c8ccd68618e8f1d37" @@ -10649,7 +10691,7 @@ readable-stream@3, readable-stream@^3.0.0, readable-stream@^3.0.2, readable-stre string_decoder "^1.1.1" util-deprecate "^1.0.1" -readable-stream@^2.0.6, readable-stream@~2.3.6: +readable-stream@^2.0.6, readable-stream@^2.2.2, readable-stream@~2.3.6: version "2.3.8" resolved "https://registry.yarnpkg.com/readable-stream/-/readable-stream-2.3.8.tgz#91125e8042bba1b9887f49345f6277027ce8be9b" integrity sha512-8p0AUk4XODgIewSi0l8Epjs+EVnWiK7NoDIEGU0HhE7+ZyY8D1IMY7odu5lRrFXGg71L15KG8QrPmum45RTtdA== @@ -11379,6 +11421,11 @@ str2buf@^1.3.0: resolved "https://registry.yarnpkg.com/str2buf/-/str2buf-1.3.0.tgz#a4172afff4310e67235178e738a2dbb573abead0" integrity sha512-xIBmHIUHYZDP4HyoXGHYNVmxlXLXDrtFHYT0eV6IOdEj3VO9ccaF1Ejl9Oq8iFjITllpT8FhaXb4KsNmw+3EuA== +streamsearch@^1.1.0: + version "1.1.0" + resolved "https://registry.yarnpkg.com/streamsearch/-/streamsearch-1.1.0.tgz#404dd1e2247ca94af554e841a8ef0eaa238da764" + integrity sha512-Mcc5wHehp9aXz1ax6bZUyY5afg9u2rv5cqQI3mRrYkGC8rW2hM02jWuwjtL++LS5qinSyhj2QfLyNsuc+VsExg== + strict-uri-encode@^2.0.0: version "2.0.0" resolved "https://registry.yarnpkg.com/strict-uri-encode/-/strict-uri-encode-2.0.0.tgz#b9c7330c7042862f6b142dc274bbcc5866ce3546" @@ -11960,7 +12007,7 @@ type-fest@^3.2.0: resolved "https://registry.yarnpkg.com/type-fest/-/type-fest-3.9.0.tgz#36a9e46e6583649f9e6098b267bc577275e9e4f4" integrity sha512-hR8JP2e8UiH7SME5JZjsobBlEiatFoxpzCP+R3ZeCo7kAaG1jXQE5X/buLzogM6GJu8le9Y4OcfNuIQX0rZskA== -type-is@~1.6.18: +type-is@^1.6.4, type-is@~1.6.18: version "1.6.18" resolved "https://registry.yarnpkg.com/type-is/-/type-is-1.6.18.tgz#4e552cd05df09467dcbc4ef739de89f2cf37c131" integrity sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g== @@ -12517,7 +12564,7 @@ xstream@^11.14.0: globalthis "^1.0.1" symbol-observable "^2.0.3" -xtend@~4.0.1: +xtend@^4.0.0, xtend@~4.0.1: version "4.0.2" resolved "https://registry.yarnpkg.com/xtend/-/xtend-4.0.2.tgz#bb72779f5fa465186b1f438f674fa347fdb5db54" integrity sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==