diff --git a/.github/actions/setup-libssl/action.yml b/.github/actions/setup-libssl/action.yml new file mode 100644 index 0000000000..9710ea6e88 --- /dev/null +++ b/.github/actions/setup-libssl/action.yml @@ -0,0 +1,22 @@ +name: Setup libSSL +description: Install libssl and libssl-dev 1.1 +author: 'gentilester@gmail.com' + +runs: + using: composite + steps: + - name: Install libssl1.1 + run: | + curl http://security.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1-1ubuntu2.1~18.04.21_amd64.deb -o libssl1.1.deb + sudo dpkg -i libssl1.1.deb + shell: bash + + - name: Instal libssl-dev.1.1 + run: | + curl http://security.ubuntu.com/ubuntu/pool/main/o/openssl/libssl-dev_1.1.1-1ubuntu2.1~18.04.21_amd64.deb -o libssl-dev1.1.deb + sudo dpkg -i libssl-dev1.1.deb + shell: bash + +branding: + icon: scissors + color: purple diff --git a/.github/actions/setup-postgres-wallet-plugin/action.yml b/.github/actions/setup-postgres-wallet-plugin/action.yml index a03b2f3fde..81f41d3578 100644 --- a/.github/actions/setup-postgres-wallet-plugin/action.yml +++ b/.github/actions/setup-postgres-wallet-plugin/action.yml @@ -10,7 +10,7 @@ runs: # so pointing rust version to 1.63.0 - name: Setup Postgres wallet plugin run: | - sudo apt-get install -y libzmq3-dev libsodium-dev pkg-config libssl-dev + sudo apt-get install -y libzmq3-dev libsodium-dev pkg-config curl https://sh.rustup.rs -sSf | bash -s -- -y export PATH="/root/.cargo/bin:${PATH}" rustup default 1.63.0 diff --git a/.github/workflows/continuous-integration.yml b/.github/workflows/continuous-integration.yml index 44820700fe..0ad780e636 100644 --- a/.github/workflows/continuous-integration.yml +++ b/.github/workflows/continuous-integration.yml @@ -26,7 +26,7 @@ jobs: # validation scripts. To still be able to run the CI we can manually trigger it by adding the 'ci-test' # label to the pull request ci-trigger: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 outputs: triggered: ${{ steps.check.outputs.triggered }} steps: @@ -45,13 +45,16 @@ jobs: echo "::set-output name=triggered::${SHOULD_RUN}" validate: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 name: Validate steps: - name: Checkout aries-framework-javascript uses: actions/checkout@v2 # setup dependencies + - name: Setup Libssl + uses: ./.github/actions/setup-libssl + - name: Setup Libindy uses: ./.github/actions/setup-libindy @@ -76,7 +79,7 @@ jobs: run: yarn build integration-test: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 name: Integration Tests strategy: @@ -88,6 +91,9 @@ jobs: uses: actions/checkout@v2 # setup dependencies + - name: Setup Libssl + uses: ./.github/actions/setup-libssl + - name: Setup Libindy uses: ./.github/actions/setup-libindy - name: Setup Indy Pool @@ -115,7 +121,7 @@ jobs: if: always() version-stable: - runs-on: ubuntu-20.04 + runs-on: ubuntu-22.04 name: Release stable needs: [integration-test, validate] if: github.ref == 'refs/heads/main' && github.event_name == 'workflow_dispatch' @@ -127,6 +133,9 @@ jobs: fetch-depth: 0 # setup dependencies + - name: Setup Libssl + uses: ./.github/actions/setup-libssl + - name: Setup Libindy uses: ./.github/actions/setup-libindy diff --git a/Dockerfile b/Dockerfile index 91ccda0363..7f55d81dfe 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM ubuntu:18.04 as base +FROM ubuntu:22.04 as base ENV DEBIAN_FRONTEND noninteractive @@ -9,7 +9,15 @@ RUN apt-get update -y && apt-get install -y \ # Only needed to build indy-sdk build-essential \ git \ - libzmq3-dev libsodium-dev pkg-config libssl-dev + libzmq3-dev libsodium-dev pkg-config + +# libssl1.1 (required by libindy) +RUN curl http://security.ubuntu.com/ubuntu/pool/main/o/openssl/libssl1.1_1.1.1-1ubuntu2.1~18.04.21_amd64.deb -o libssl1.1.deb +RUN dpkg -i libssl1.1.deb + +# libssl-dev1.1 (required to compile libindy with posgres plugin) +RUN curl http://security.ubuntu.com/ubuntu/pool/main/o/openssl/libssl-dev_1.1.1-1ubuntu2.1~18.04.21_amd64.deb -o libssl-dev1.1.deb +RUN dpkg -i libssl-dev1.1.deb # libindy RUN apt-key adv --keyserver keyserver.ubuntu.com --recv-keys CE7709D068DB5E88 diff --git a/packages/anoncreds-rs/README.md b/packages/anoncreds-rs/README.md new file mode 100644 index 0000000000..87f28670e7 --- /dev/null +++ b/packages/anoncreds-rs/README.md @@ -0,0 +1,31 @@ +

+
+ Hyperledger Aries logo +

+

Aries Framework JavaScript AnonCreds RS Module

+

+ License + typescript + @aries-framework/anoncreds-rs version + +

+
+ +AnonCreds RS module for [Aries Framework JavaScript](https://github.com/hyperledger/aries-framework-javascript.git). diff --git a/packages/anoncreds-rs/jest.config.ts b/packages/anoncreds-rs/jest.config.ts new file mode 100644 index 0000000000..55c67d70a6 --- /dev/null +++ b/packages/anoncreds-rs/jest.config.ts @@ -0,0 +1,14 @@ +import type { Config } from '@jest/types' + +import base from '../../jest.config.base' + +import packageJson from './package.json' + +const config: Config.InitialOptions = { + ...base, + name: packageJson.name, + displayName: packageJson.name, + setupFilesAfterEnv: ['./tests/setup.ts'], +} + +export default config diff --git a/packages/anoncreds-rs/package.json b/packages/anoncreds-rs/package.json new file mode 100644 index 0000000000..d60aa4f4ca --- /dev/null +++ b/packages/anoncreds-rs/package.json @@ -0,0 +1,41 @@ +{ + "name": "@aries-framework/anoncreds-rs", + "main": "build/index", + "types": "build/index", + "version": "0.3.3", + "private": true, + "files": [ + "build" + ], + "license": "Apache-2.0", + "publishConfig": { + "access": "public" + }, + "homepage": "https://github.com/hyperledger/aries-framework-javascript/tree/main/packages/anoncreds-rs", + "repository": { + "type": "git", + "url": "https://github.com/hyperledger/aries-framework-javascript", + "directory": "packages/anoncreds-rs" + }, + "scripts": { + "build": "yarn run clean && yarn run compile", + "clean": "rimraf ./build", + "compile": "tsc -p tsconfig.build.json", + "prepublishOnly": "yarn run build", + "test": "jest" + }, + "dependencies": { + "@aries-framework/core": "0.3.3", + "@aries-framework/anoncreds": "0.3.3", + "@hyperledger/anoncreds-shared": "^0.1.0-dev.5", + "class-transformer": "^0.5.1", + "class-validator": "^0.14.0", + "rxjs": "^7.2.0", + "tsyringe": "^4.7.0" + }, + "devDependencies": { + "@hyperledger/anoncreds-nodejs": "^0.1.0-dev.5", + "rimraf": "^4.0.7", + "typescript": "~4.9.4" + } +} diff --git a/packages/anoncreds-rs/src/AnonCredsRsModule.ts b/packages/anoncreds-rs/src/AnonCredsRsModule.ts new file mode 100644 index 0000000000..4ceb7b8304 --- /dev/null +++ b/packages/anoncreds-rs/src/AnonCredsRsModule.ts @@ -0,0 +1,29 @@ +import type { DependencyManager, Module } from '@aries-framework/core' + +import { + AnonCredsHolderServiceSymbol, + AnonCredsIssuerServiceSymbol, + AnonCredsVerifierServiceSymbol, +} from '@aries-framework/anoncreds' + +import { AnonCredsRsHolderService, AnonCredsRsIssuerService, AnonCredsRsVerifierService } from './services' + +export class AnonCredsRsModule implements Module { + public register(dependencyManager: DependencyManager) { + try { + // eslint-disable-next-line import/no-extraneous-dependencies + require('@hyperledger/anoncreds-nodejs') + } catch (error) { + try { + require('@hyperledger/anoncreds-react-native') + } catch (error) { + throw new Error('Could not load anoncreds bindings') + } + } + + // Register services + dependencyManager.registerSingleton(AnonCredsHolderServiceSymbol, AnonCredsRsHolderService) + dependencyManager.registerSingleton(AnonCredsIssuerServiceSymbol, AnonCredsRsIssuerService) + dependencyManager.registerSingleton(AnonCredsVerifierServiceSymbol, AnonCredsRsVerifierService) + } +} diff --git a/packages/anoncreds-rs/src/errors/AnonCredsRsError.ts b/packages/anoncreds-rs/src/errors/AnonCredsRsError.ts new file mode 100644 index 0000000000..e8cdf3023d --- /dev/null +++ b/packages/anoncreds-rs/src/errors/AnonCredsRsError.ts @@ -0,0 +1,7 @@ +import { AriesFrameworkError } from '@aries-framework/core' + +export class AnonCredsRsError extends AriesFrameworkError { + public constructor(message: string, { cause }: { cause?: Error } = {}) { + super(message, { cause }) + } +} diff --git a/packages/anoncreds-rs/src/index.ts b/packages/anoncreds-rs/src/index.ts new file mode 100644 index 0000000000..5fdd9486c7 --- /dev/null +++ b/packages/anoncreds-rs/src/index.ts @@ -0,0 +1,5 @@ +// Services +export * from './services' + +// Module +export { AnonCredsRsModule } from './AnonCredsRsModule' diff --git a/packages/anoncreds-rs/src/services/AnonCredsRsHolderService.ts b/packages/anoncreds-rs/src/services/AnonCredsRsHolderService.ts new file mode 100644 index 0000000000..e0c84fd7b1 --- /dev/null +++ b/packages/anoncreds-rs/src/services/AnonCredsRsHolderService.ts @@ -0,0 +1,374 @@ +import type { + AnonCredsHolderService, + AnonCredsProof, + CreateCredentialRequestOptions, + CreateCredentialRequestReturn, + CreateProofOptions, + GetCredentialOptions, + StoreCredentialOptions, + GetCredentialsForProofRequestOptions, + GetCredentialsForProofRequestReturn, + AnonCredsCredentialInfo, + CreateLinkSecretOptions, + CreateLinkSecretReturn, + AnonCredsProofRequestRestriction, + AnonCredsRequestedAttribute, + AnonCredsRequestedPredicate, + AnonCredsCredential, +} from '@aries-framework/anoncreds' +import type { AgentContext, Query, SimpleQuery } from '@aries-framework/core' +import type { CredentialEntry, CredentialProve } from '@hyperledger/anoncreds-shared' + +import { + AnonCredsCredentialRecord, + AnonCredsLinkSecretRepository, + AnonCredsCredentialRepository, +} from '@aries-framework/anoncreds' +import { injectable } from '@aries-framework/core' +import { + CredentialRequestMetadata, + Credential, + CredentialDefinition, + CredentialOffer, + CredentialRequest, + CredentialRevocationState, + MasterSecret, + Presentation, + PresentationRequest, + RevocationRegistryDefinition, + RevocationStatusList, + Schema, +} from '@hyperledger/anoncreds-shared' + +import { uuid } from '../../../core/src/utils/uuid' +import { AnonCredsRsError } from '../errors/AnonCredsRsError' + +@injectable() +export class AnonCredsRsHolderService implements AnonCredsHolderService { + public async createLinkSecret( + agentContext: AgentContext, + options?: CreateLinkSecretOptions + ): Promise { + try { + return { + linkSecretId: options?.linkSecretId ?? uuid(), + linkSecretValue: JSON.parse(MasterSecret.create().toJson()).value.ms, + } + } catch (error) { + agentContext.config.logger.error(`Error creating Link Secret`, { + error, + }) + throw new AnonCredsRsError('Error creating Link Secret', { cause: error }) + } + } + + public async createProof(agentContext: AgentContext, options: CreateProofOptions): Promise { + const { credentialDefinitions, proofRequest, requestedCredentials, schemas } = options + + try { + const rsCredentialDefinitions: Record = {} + for (const credDefId in credentialDefinitions) { + rsCredentialDefinitions[credDefId] = CredentialDefinition.load(JSON.stringify(credentialDefinitions[credDefId])) + } + + const rsSchemas: Record = {} + for (const schemaId in schemas) { + rsSchemas[schemaId] = Schema.load(JSON.stringify(schemas[schemaId])) + } + + const credentialRepository = agentContext.dependencyManager.resolve(AnonCredsCredentialRepository) + + // Cache retrieved credentials in order to minimize storage calls + const retrievedCredentials = new Map() + + const credentialEntryFromAttribute = async ( + attribute: AnonCredsRequestedAttribute | AnonCredsRequestedPredicate + ): Promise<{ linkSecretId: string; credentialEntry: CredentialEntry }> => { + let credentialRecord = retrievedCredentials.get(attribute.credentialId) + if (!credentialRecord) { + credentialRecord = await credentialRepository.getByCredentialId(agentContext, attribute.credentialId) + retrievedCredentials.set(attribute.credentialId, credentialRecord) + } + + const credential = Credential.load(JSON.stringify(credentialRecord.credential)) + + const revocationRegistryDefinitionId = credential.revocationRegistryId + const revocationRegistryIndex = credential.revocationRegistryIndex + + // TODO: Check if credential has a revocation registry id (check response from anoncreds-rs API, as it is + // sending back a mandatory string in Credential.revocationRegistryId) + const timestamp = attribute.timestamp + + let revocationState + if (timestamp) { + if (revocationRegistryIndex) { + if (!options.revocationRegistries[revocationRegistryDefinitionId]) { + throw new AnonCredsRsError(`Revocation Registry ${revocationRegistryDefinitionId} not found`) + } + + const { definition, tailsFilePath } = options.revocationRegistries[revocationRegistryDefinitionId] + + const revocationRegistryDefinition = RevocationRegistryDefinition.load(JSON.stringify(definition)) + revocationState = CredentialRevocationState.create({ + revocationRegistryIndex, + revocationRegistryDefinition, + tailsPath: tailsFilePath, + revocationStatusList: RevocationStatusList.create({ + issuanceByDefault: true, + revocationRegistryDefinition, + revocationRegistryDefinitionId, + timestamp, + }), + }) + } + } + return { + linkSecretId: credentialRecord.linkSecretId, + credentialEntry: { + credential, + revocationState, + timestamp, + }, + } + } + + const credentialsProve: CredentialProve[] = [] + const credentials: { linkSecretId: string; credentialEntry: CredentialEntry }[] = [] + + let entryIndex = 0 + for (const referent in requestedCredentials.requestedAttributes) { + const attribute = requestedCredentials.requestedAttributes[referent] + credentials.push(await credentialEntryFromAttribute(attribute)) + credentialsProve.push({ entryIndex, isPredicate: false, referent, reveal: attribute.revealed }) + entryIndex = entryIndex + 1 + } + + for (const referent in requestedCredentials.requestedPredicates) { + const predicate = requestedCredentials.requestedPredicates[referent] + credentials.push(await credentialEntryFromAttribute(predicate)) + credentialsProve.push({ entryIndex, isPredicate: true, referent, reveal: true }) + entryIndex = entryIndex + 1 + } + + // Get all requested credentials and take linkSecret. If it's not the same for every credential, throw error + const linkSecretsMatch = credentials.every((item) => item.linkSecretId === credentials[0].linkSecretId) + if (!linkSecretsMatch) { + throw new AnonCredsRsError('All credentials in a Proof should have been issued using the same Link Secret') + } + + const linkSecretRecord = await agentContext.dependencyManager + .resolve(AnonCredsLinkSecretRepository) + .getByLinkSecretId(agentContext, credentials[0].linkSecretId) + + if (!linkSecretRecord.value) { + throw new AnonCredsRsError('Link Secret value not stored') + } + + const presentation = Presentation.create({ + credentialDefinitions: rsCredentialDefinitions, + schemas: rsSchemas, + presentationRequest: PresentationRequest.load(JSON.stringify(proofRequest)), + credentials: credentials.map((entry) => entry.credentialEntry), + credentialsProve, + selfAttest: requestedCredentials.selfAttestedAttributes, + masterSecret: MasterSecret.load(JSON.stringify({ value: { ms: linkSecretRecord.value } })), + }) + + return JSON.parse(presentation.toJson()) + } catch (error) { + agentContext.config.logger.error(`Error creating AnonCreds Proof`, { + error, + proofRequest, + requestedCredentials, + }) + throw new AnonCredsRsError(`Error creating proof: ${error}`, { cause: error }) + } + } + + public async createCredentialRequest( + agentContext: AgentContext, + options: CreateCredentialRequestOptions + ): Promise { + const { credentialDefinition, credentialOffer } = options + try { + const linkSecretRepository = agentContext.dependencyManager.resolve(AnonCredsLinkSecretRepository) + + // If a link secret is specified, use it. Otherwise, attempt to use default link secret + const linkSecretRecord = options.linkSecretId + ? await linkSecretRepository.getByLinkSecretId(agentContext, options.linkSecretId) + : await linkSecretRepository.findDefault(agentContext) + + if (!linkSecretRecord) { + // No default link secret + throw new AnonCredsRsError('No default link secret has been found') + } + + const { credentialRequest, credentialRequestMetadata } = CredentialRequest.create({ + credentialDefinition: CredentialDefinition.load(JSON.stringify(credentialDefinition)), + credentialOffer: CredentialOffer.load(JSON.stringify(credentialOffer)), + masterSecret: MasterSecret.load(JSON.stringify({ value: { ms: linkSecretRecord.value } })), + masterSecretId: linkSecretRecord.linkSecretId, + }) + + return { + credentialRequest: JSON.parse(credentialRequest.toJson()), + credentialRequestMetadata: JSON.parse(credentialRequestMetadata.toJson()), + } + } catch (error) { + throw new AnonCredsRsError(`Error creating credential request: ${error}`, { cause: error }) + } + } + + public async storeCredential(agentContext: AgentContext, options: StoreCredentialOptions): Promise { + const { credential, credentialDefinition, credentialRequestMetadata, revocationRegistry, schema } = options + + const linkSecretRecord = await agentContext.dependencyManager + .resolve(AnonCredsLinkSecretRepository) + .getByLinkSecretId(agentContext, credentialRequestMetadata.master_secret_name) + + const revocationRegistryDefinition = revocationRegistry?.definition + ? RevocationRegistryDefinition.load(JSON.stringify(revocationRegistry.definition)) + : undefined + + const credentialId = options.credentialId ?? uuid() + const processedCredential = Credential.load(JSON.stringify(credential)).process({ + credentialDefinition: CredentialDefinition.load(JSON.stringify(credentialDefinition)), + credentialRequestMetadata: CredentialRequestMetadata.load(JSON.stringify(credentialRequestMetadata)), + masterSecret: MasterSecret.load(JSON.stringify({ value: { ms: linkSecretRecord.value } })), + revocationRegistryDefinition, + }) + + const credentialRepository = agentContext.dependencyManager.resolve(AnonCredsCredentialRepository) + + await credentialRepository.save( + agentContext, + new AnonCredsCredentialRecord({ + credential: JSON.parse(processedCredential.toJson()) as AnonCredsCredential, + credentialId, + linkSecretId: linkSecretRecord.linkSecretId, + issuerId: options.credentialDefinition.issuerId, + schemaName: schema.name, + schemaIssuerId: schema.issuerId, + schemaVersion: schema.version, + }) + ) + + return credentialId + } + + public async getCredential( + agentContext: AgentContext, + options: GetCredentialOptions + ): Promise { + const credentialRecord = await agentContext.dependencyManager + .resolve(AnonCredsCredentialRepository) + .getByCredentialId(agentContext, options.credentialId) + + const attributes: { [key: string]: string } = {} + for (const attribute in credentialRecord.credential.values) { + attributes[attribute] = credentialRecord.credential.values[attribute].raw + } + return { + attributes, + credentialDefinitionId: credentialRecord.credential.cred_def_id, + credentialId: credentialRecord.credentialId, + schemaId: credentialRecord.credential.schema_id, + credentialRevocationId: credentialRecord.credentialRevocationId, + revocationRegistryId: credentialRecord.credential.rev_reg_id, + } + } + + public async deleteCredential(agentContext: AgentContext, credentialId: string): Promise { + const credentialRepository = agentContext.dependencyManager.resolve(AnonCredsCredentialRepository) + const credentialRecord = await credentialRepository.getByCredentialId(agentContext, credentialId) + await credentialRepository.delete(agentContext, credentialRecord) + } + + public async getCredentialsForProofRequest( + agentContext: AgentContext, + options: GetCredentialsForProofRequestOptions + ): Promise { + const proofRequest = options.proofRequest + const referent = options.attributeReferent + + const requestedAttribute = + proofRequest.requested_attributes[referent] ?? proofRequest.requested_predicates[referent] + + if (!requestedAttribute) { + throw new AnonCredsRsError(`Referent not found in proof request`) + } + const attributes = requestedAttribute.name ? [requestedAttribute.name] : requestedAttribute.names + + const restrictionQuery = requestedAttribute.restrictions + ? this.queryFromRestrictions(requestedAttribute.restrictions) + : undefined + + const query: Query = { + attributes, + ...restrictionQuery, + ...options.extraQuery, + } + + const credentials = await agentContext.dependencyManager + .resolve(AnonCredsCredentialRepository) + .findByQuery(agentContext, query) + + return credentials.map((credentialRecord) => { + const attributes: { [key: string]: string } = {} + for (const attribute in credentialRecord.credential.values) { + attributes[attribute] = credentialRecord.credential.values[attribute].raw + } + return { + credentialInfo: { + attributes, + credentialDefinitionId: credentialRecord.credential.cred_def_id, + credentialId: credentialRecord.credentialId, + schemaId: credentialRecord.credential.schema_id, + credentialRevocationId: credentialRecord.credentialRevocationId, + revocationRegistryId: credentialRecord.credential.rev_reg_id, + }, + interval: proofRequest.non_revoked, + } + }) + } + + private queryFromRestrictions(restrictions: AnonCredsProofRequestRestriction[]) { + const query: Query[] = [] + + for (const restriction of restrictions) { + const queryElements: SimpleQuery = {} + + if (restriction.cred_def_id) { + queryElements.credentialDefinitionId = restriction.cred_def_id + } + + if (restriction.issuer_id || restriction.issuer_did) { + queryElements.issuerId = restriction.issuer_id ?? restriction.issuer_did + } + + if (restriction.rev_reg_id) { + queryElements.revocationRegistryId = restriction.rev_reg_id + } + + if (restriction.schema_id) { + queryElements.schemaId = restriction.schema_id + } + + if (restriction.schema_issuer_id || restriction.schema_issuer_did) { + queryElements.schemaIssuerId = restriction.schema_issuer_id ?? restriction.schema_issuer_did + } + + if (restriction.schema_name) { + queryElements.schemaName = restriction.schema_name + } + + if (restriction.schema_version) { + queryElements.schemaVersion = restriction.schema_version + } + + query.push(queryElements) + } + + return query.length === 1 ? query[0] : { $or: query } + } +} diff --git a/packages/anoncreds-rs/src/services/AnonCredsRsIssuerService.ts b/packages/anoncreds-rs/src/services/AnonCredsRsIssuerService.ts new file mode 100644 index 0000000000..17b3c91d91 --- /dev/null +++ b/packages/anoncreds-rs/src/services/AnonCredsRsIssuerService.ts @@ -0,0 +1,158 @@ +import type { + AnonCredsIssuerService, + CreateCredentialDefinitionOptions, + CreateCredentialOfferOptions, + CreateCredentialOptions, + CreateCredentialReturn, + CreateSchemaOptions, + AnonCredsCredentialOffer, + AnonCredsSchema, + AnonCredsCredentialDefinition, + CreateCredentialDefinitionReturn, +} from '@aries-framework/anoncreds' +import type { AgentContext } from '@aries-framework/core' + +import { + AnonCredsKeyCorrectnessProofRepository, + AnonCredsCredentialDefinitionPrivateRepository, + AnonCredsCredentialDefinitionRepository, +} from '@aries-framework/anoncreds' +import { injectable, AriesFrameworkError } from '@aries-framework/core' +import { + Credential, + CredentialDefinition, + CredentialDefinitionPrivate, + CredentialRequest, + CredentialOffer, + KeyCorrectnessProof, + Schema, +} from '@hyperledger/anoncreds-shared' + +import { AnonCredsRsError } from '../errors/AnonCredsRsError' + +@injectable() +export class AnonCredsRsIssuerService implements AnonCredsIssuerService { + public async createSchema(agentContext: AgentContext, options: CreateSchemaOptions): Promise { + const { issuerId, name, version, attrNames: attributeNames } = options + + try { + const schema = Schema.create({ + issuerId, + name, + version, + attributeNames, + }) + + return JSON.parse(schema.toJson()) as AnonCredsSchema + } catch (error) { + throw new AnonCredsRsError('Error creating schema', { cause: error }) + } + } + + public async createCredentialDefinition( + agentContext: AgentContext, + options: CreateCredentialDefinitionOptions + ): Promise { + const { tag, supportRevocation, schema, issuerId, schemaId } = options + + try { + const { credentialDefinition, credentialDefinitionPrivate, keyCorrectnessProof } = CredentialDefinition.create({ + schema: Schema.load(JSON.stringify(schema)), + issuerId, + schemaId, + tag, + supportRevocation, + signatureType: 'CL', + }) + + return { + credentialDefinition: JSON.parse(credentialDefinition.toJson()) as AnonCredsCredentialDefinition, + credentialDefinitionPrivate: JSON.parse(credentialDefinitionPrivate.toJson()), + keyCorrectnessProof: JSON.parse(keyCorrectnessProof.toJson()), + } + } catch (error) { + throw new AnonCredsRsError('Error creating credential definition', { cause: error }) + } + } + + public async createCredentialOffer( + agentContext: AgentContext, + options: CreateCredentialOfferOptions + ): Promise { + const { credentialDefinitionId } = options + + try { + const credentialDefinitionRecord = await agentContext.dependencyManager + .resolve(AnonCredsCredentialDefinitionRepository) + .getByCredentialDefinitionId(agentContext, options.credentialDefinitionId) + + const keyCorrectnessProofRecord = await agentContext.dependencyManager + .resolve(AnonCredsKeyCorrectnessProofRepository) + .getByCredentialDefinitionId(agentContext, options.credentialDefinitionId) + + if (!credentialDefinitionRecord) { + throw new AnonCredsRsError(`Credential Definition ${credentialDefinitionId} not found`) + } + + const credentialOffer = CredentialOffer.create({ + credentialDefinitionId, + keyCorrectnessProof: KeyCorrectnessProof.load(JSON.stringify(keyCorrectnessProofRecord?.value)), + schemaId: credentialDefinitionRecord.credentialDefinition.schemaId, + }) + + return JSON.parse(credentialOffer.toJson()) as AnonCredsCredentialOffer + } catch (error) { + throw new AnonCredsRsError(`Error creating credential offer: ${error}`, { cause: error }) + } + } + + public async createCredential( + agentContext: AgentContext, + options: CreateCredentialOptions + ): Promise { + const { tailsFilePath, credentialOffer, credentialRequest, credentialValues, revocationRegistryId } = options + + try { + if (revocationRegistryId || tailsFilePath) { + throw new AriesFrameworkError('Revocation not supported yet') + } + + const attributeRawValues: Record = {} + const attributeEncodedValues: Record = {} + + Object.keys(credentialValues).forEach((key) => { + attributeRawValues[key] = credentialValues[key].raw + attributeEncodedValues[key] = credentialValues[key].encoded + }) + + const credentialDefinitionRecord = await agentContext.dependencyManager + .resolve(AnonCredsCredentialDefinitionRepository) + .getByCredentialDefinitionId(agentContext, options.credentialRequest.cred_def_id) + + const credentialDefinitionPrivateRecord = await agentContext.dependencyManager + .resolve(AnonCredsCredentialDefinitionPrivateRepository) + .getByCredentialDefinitionId(agentContext, options.credentialRequest.cred_def_id) + + const credential = Credential.create({ + credentialDefinition: CredentialDefinition.load( + JSON.stringify(credentialDefinitionRecord.credentialDefinition) + ), + credentialOffer: CredentialOffer.load(JSON.stringify(credentialOffer)), + credentialRequest: CredentialRequest.load(JSON.stringify(credentialRequest)), + revocationRegistryId, + attributeEncodedValues, + attributeRawValues, + credentialDefinitionPrivate: CredentialDefinitionPrivate.load( + JSON.stringify(credentialDefinitionPrivateRecord.value) + ), + }) + + return { + credential: JSON.parse(credential.toJson()), + credentialRevocationId: credential.revocationRegistryIndex?.toString(), + } + } catch (error) { + throw new AnonCredsRsError(`Error creating credential: ${error}`, { cause: error }) + } + } +} diff --git a/packages/anoncreds-rs/src/services/AnonCredsRsVerifierService.ts b/packages/anoncreds-rs/src/services/AnonCredsRsVerifierService.ts new file mode 100644 index 0000000000..96030d44ba --- /dev/null +++ b/packages/anoncreds-rs/src/services/AnonCredsRsVerifierService.ts @@ -0,0 +1,66 @@ +import type { AnonCredsVerifierService, VerifyProofOptions } from '@aries-framework/anoncreds' + +import { injectable } from '@aries-framework/core' +import { + CredentialDefinition, + Presentation, + PresentationRequest, + RevocationRegistryDefinition, + RevocationStatusList, + Schema, +} from '@hyperledger/anoncreds-shared' + +import { AnonCredsRsError } from '../errors/AnonCredsRsError' + +@injectable() +export class AnonCredsRsVerifierService implements AnonCredsVerifierService { + public async verifyProof(options: VerifyProofOptions): Promise { + const { credentialDefinitions, proof, proofRequest, revocationStates, schemas } = options + + try { + const presentation = Presentation.load(JSON.stringify(proof)) + + const rsCredentialDefinitions: Record = {} + for (const credDefId in credentialDefinitions) { + rsCredentialDefinitions[credDefId] = CredentialDefinition.load(JSON.stringify(credentialDefinitions[credDefId])) + } + + const rsSchemas: Record = {} + for (const schemaId in schemas) { + rsSchemas[schemaId] = Schema.load(JSON.stringify(schemas[schemaId])) + } + + const revocationRegistryDefinitions: Record = {} + const lists = [] + + for (const revocationRegistryDefinitionId in revocationStates) { + const { definition, revocationStatusLists } = options.revocationStates[revocationRegistryDefinitionId] + + revocationRegistryDefinitions[revocationRegistryDefinitionId] = RevocationRegistryDefinition.load( + JSON.stringify(definition) + ) + + for (const timestamp in revocationStatusLists) { + lists.push( + RevocationStatusList.create({ + issuanceByDefault: true, + revocationRegistryDefinition: revocationRegistryDefinitions[revocationRegistryDefinitionId], + revocationRegistryDefinitionId, + timestamp: Number(timestamp), + }) + ) + } + } + + return presentation.verify({ + presentationRequest: PresentationRequest.load(JSON.stringify(proofRequest)), + credentialDefinitions: rsCredentialDefinitions, + schemas: rsSchemas, + revocationRegistryDefinitions, + revocationStatusLists: lists, + }) + } catch (error) { + throw new AnonCredsRsError('Error verifying proof', { cause: error }) + } + } +} diff --git a/packages/anoncreds-rs/src/services/__tests__/AnonCredsRsHolderService.test.ts b/packages/anoncreds-rs/src/services/__tests__/AnonCredsRsHolderService.test.ts new file mode 100644 index 0000000000..f0585f6ffb --- /dev/null +++ b/packages/anoncreds-rs/src/services/__tests__/AnonCredsRsHolderService.test.ts @@ -0,0 +1,501 @@ +import type { + AnonCredsCredentialDefinition, + AnonCredsProofRequest, + AnonCredsRequestedCredentials, + AnonCredsRevocationStatusList, + AnonCredsCredential, + AnonCredsSchema, +} from '@aries-framework/anoncreds' + +import { + AnonCredsHolderServiceSymbol, + AnonCredsLinkSecretRecord, + AnonCredsCredentialRecord, +} from '@aries-framework/anoncreds' +import { anoncreds, RevocationRegistryDefinition } from '@hyperledger/anoncreds-nodejs' + +import { AnonCredsCredentialDefinitionRepository } from '../../../../anoncreds/src/repository/AnonCredsCredentialDefinitionRepository' +import { AnonCredsCredentialRepository } from '../../../../anoncreds/src/repository/AnonCredsCredentialRepository' +import { AnonCredsLinkSecretRepository } from '../../../../anoncreds/src/repository/AnonCredsLinkSecretRepository' +import { getAgentConfig, getAgentContext, mockFunction } from '../../../../core/tests/helpers' +import { AnonCredsRsHolderService } from '../AnonCredsRsHolderService' + +import { + createCredentialDefinition, + createCredentialForHolder, + createCredentialOffer, + createLinkSecret, +} from './helpers' + +const agentConfig = getAgentConfig('AnonCredsRsHolderServiceTest') +const anonCredsHolderService = new AnonCredsRsHolderService() + +jest.mock('../../../../anoncreds/src/repository/AnonCredsCredentialDefinitionRepository') +const CredentialDefinitionRepositoryMock = + AnonCredsCredentialDefinitionRepository as jest.Mock +const credentialDefinitionRepositoryMock = new CredentialDefinitionRepositoryMock() + +jest.mock('../../../../anoncreds/src/repository/AnonCredsLinkSecretRepository') +const AnonCredsLinkSecretRepositoryMock = AnonCredsLinkSecretRepository as jest.Mock +const anoncredsLinkSecretRepositoryMock = new AnonCredsLinkSecretRepositoryMock() + +jest.mock('../../../../anoncreds/src/repository/AnonCredsCredentialRepository') +const AnonCredsCredentialRepositoryMock = AnonCredsCredentialRepository as jest.Mock +const anoncredsCredentialRepositoryMock = new AnonCredsCredentialRepositoryMock() + +const agentContext = getAgentContext({ + registerInstances: [ + [AnonCredsCredentialDefinitionRepository, credentialDefinitionRepositoryMock], + [AnonCredsLinkSecretRepository, anoncredsLinkSecretRepositoryMock], + [AnonCredsCredentialRepository, anoncredsCredentialRepositoryMock], + [AnonCredsHolderServiceSymbol, anonCredsHolderService], + ], + agentConfig, +}) + +describe('AnonCredsRsHolderService', () => { + const getByCredentialIdMock = jest.spyOn(anoncredsCredentialRepositoryMock, 'getByCredentialId') + + afterEach(() => { + getByCredentialIdMock.mockClear() + }) + + test('createCredentialRequest', async () => { + mockFunction(anoncredsLinkSecretRepositoryMock.getByLinkSecretId).mockResolvedValue( + new AnonCredsLinkSecretRecord({ linkSecretId: 'linkSecretId', value: createLinkSecret() }) + ) + + const { credentialDefinition, keyCorrectnessProof } = createCredentialDefinition({ + attributeNames: ['phoneNumber'], + issuerId: 'issuer:uri', + }) + const credentialOffer = createCredentialOffer(keyCorrectnessProof) + + const { credentialRequest } = await anonCredsHolderService.createCredentialRequest(agentContext, { + credentialDefinition, + credentialOffer, + linkSecretId: 'linkSecretId', + }) + + expect(credentialRequest.cred_def_id).toBe('creddef:uri') + expect(credentialRequest.prover_did).toBeUndefined() + }) + + test('createLinkSecret', async () => { + let linkSecret = await anonCredsHolderService.createLinkSecret(agentContext, { + linkSecretId: 'linkSecretId', + }) + + expect(linkSecret.linkSecretId).toBe('linkSecretId') + expect(linkSecret.linkSecretValue).toBeDefined() + + linkSecret = await anonCredsHolderService.createLinkSecret(agentContext) + + expect(linkSecret.linkSecretId).toBeDefined() + expect(linkSecret.linkSecretValue).toBeDefined() + }) + + test('createProof', async () => { + const proofRequest: AnonCredsProofRequest = { + nonce: anoncreds.generateNonce(), + name: 'pres_req_1', + version: '0.1', + requested_attributes: { + attr1_referent: { + name: 'name', + restrictions: [{ issuer_did: 'issuer:uri' }], + }, + attr2_referent: { + name: 'phoneNumber', + }, + attr3_referent: { + name: 'age', + }, + attr4_referent: { + names: ['name', 'height'], + }, + attr5_referent: { + name: 'favouriteSport', + }, + }, + requested_predicates: { + predicate1_referent: { name: 'age', p_type: '>=' as const, p_value: 18 }, + }, + //non_revoked: { from: 10, to: 200 }, + } + + const { + credentialDefinition: personCredentialDefinition, + credentialDefinitionPrivate: personCredentialDefinitionPrivate, + keyCorrectnessProof: personKeyCorrectnessProof, + } = createCredentialDefinition({ + attributeNames: ['name', 'age', 'sex', 'height'], + issuerId: 'issuer:uri', + }) + + const { + credentialDefinition: phoneCredentialDefinition, + credentialDefinitionPrivate: phoneCredentialDefinitionPrivate, + keyCorrectnessProof: phoneKeyCorrectnessProof, + } = createCredentialDefinition({ + attributeNames: ['phoneNumber'], + issuerId: 'issuer:uri', + }) + + const linkSecret = createLinkSecret() + + mockFunction(anoncredsLinkSecretRepositoryMock.getByLinkSecretId).mockResolvedValue( + new AnonCredsLinkSecretRecord({ linkSecretId: 'linkSecretId', value: linkSecret }) + ) + + const { + credential: personCredential, + credentialInfo: personCredentialInfo, + revocationRegistryDefinition: personRevRegDef, + tailsPath: personTailsPath, + } = createCredentialForHolder({ + attributes: { + name: 'John', + sex: 'M', + height: '179', + age: '19', + }, + credentialDefinition: personCredentialDefinition, + schemaId: 'personschema:uri', + credentialDefinitionId: 'personcreddef:uri', + credentialDefinitionPrivate: personCredentialDefinitionPrivate, + keyCorrectnessProof: personKeyCorrectnessProof, + linkSecret, + linkSecretId: 'linkSecretId', + credentialId: 'personCredId', + revocationRegistryDefinitionId: 'personrevregid:uri', + }) + + const { + credential: phoneCredential, + credentialInfo: phoneCredentialInfo, + revocationRegistryDefinition: phoneRevRegDef, + tailsPath: phoneTailsPath, + } = createCredentialForHolder({ + attributes: { + phoneNumber: 'linkSecretId56', + }, + credentialDefinition: phoneCredentialDefinition, + schemaId: 'phoneschema:uri', + credentialDefinitionId: 'phonecreddef:uri', + credentialDefinitionPrivate: phoneCredentialDefinitionPrivate, + keyCorrectnessProof: phoneKeyCorrectnessProof, + linkSecret, + linkSecretId: 'linkSecretId', + credentialId: 'phoneCredId', + revocationRegistryDefinitionId: 'phonerevregid:uri', + }) + + const requestedCredentials: AnonCredsRequestedCredentials = { + selfAttestedAttributes: { attr5_referent: 'football' }, + requestedAttributes: { + attr1_referent: { credentialId: 'personCredId', credentialInfo: personCredentialInfo, revealed: true }, + attr2_referent: { credentialId: 'phoneCredId', credentialInfo: phoneCredentialInfo, revealed: true }, + attr3_referent: { credentialId: 'personCredId', credentialInfo: personCredentialInfo, revealed: true }, + attr4_referent: { credentialId: 'personCredId', credentialInfo: personCredentialInfo, revealed: true }, + }, + requestedPredicates: { + predicate1_referent: { credentialId: 'personCredId', credentialInfo: personCredentialInfo }, + }, + } + + getByCredentialIdMock.mockResolvedValueOnce( + new AnonCredsCredentialRecord({ + credential: personCredential, + credentialId: 'personCredId', + linkSecretId: 'linkSecretId', + issuerId: 'issuerDid', + schemaIssuerId: 'schemaIssuerDid', + schemaName: 'schemaName', + schemaVersion: 'schemaVersion', + }) + ) + getByCredentialIdMock.mockResolvedValueOnce( + new AnonCredsCredentialRecord({ + credential: phoneCredential, + credentialId: 'phoneCredId', + linkSecretId: 'linkSecretId', + issuerId: 'issuerDid', + schemaIssuerId: 'schemaIssuerDid', + schemaName: 'schemaName', + schemaVersion: 'schemaVersion', + }) + ) + + const revocationRegistries = { + 'personrevregid:uri': { + tailsFilePath: personTailsPath, + definition: JSON.parse(anoncreds.getJson({ objectHandle: personRevRegDef })), + revocationStatusLists: { '1': {} as AnonCredsRevocationStatusList }, + }, + 'phonerevregid:uri': { + tailsFilePath: phoneTailsPath, + definition: JSON.parse(anoncreds.getJson({ objectHandle: phoneRevRegDef })), + revocationStatusLists: { '1': {} as AnonCredsRevocationStatusList }, + }, + } + + const proof = await anonCredsHolderService.createProof(agentContext, { + credentialDefinitions: { + 'personcreddef:uri': personCredentialDefinition as AnonCredsCredentialDefinition, + 'phonecreddef:uri': phoneCredentialDefinition as AnonCredsCredentialDefinition, + }, + proofRequest, + requestedCredentials, + schemas: { + 'phoneschema:uri': { attrNames: ['phoneNumber'], issuerId: 'issuer:uri', name: 'phoneschema', version: '1' }, + 'personschema:uri': { + attrNames: ['name', 'sex', 'height', 'age'], + issuerId: 'issuer:uri', + name: 'personschema', + version: '1', + }, + }, + revocationRegistries, + }) + + expect(getByCredentialIdMock).toHaveBeenCalledTimes(2) + // TODO: check proof object + }) + + describe('getCredentialsForProofRequest', () => { + const findByQueryMock = jest.spyOn(anoncredsCredentialRepositoryMock, 'findByQuery') + + const proofRequest: AnonCredsProofRequest = { + nonce: anoncreds.generateNonce(), + name: 'pres_req_1', + version: '0.1', + requested_attributes: { + attr1_referent: { + name: 'name', + restrictions: [{ issuer_did: 'issuer:uri' }], + }, + attr2_referent: { + name: 'phoneNumber', + }, + attr3_referent: { + name: 'age', + restrictions: [{ schema_id: 'schemaid:uri', schema_name: 'schemaName' }, { schema_version: '1.0' }], + }, + attr4_referent: { + names: ['name', 'height'], + restrictions: [{ cred_def_id: 'crededefid:uri', issuer_id: 'issuerid:uri' }], + }, + }, + requested_predicates: { + predicate1_referent: { name: 'age', p_type: '>=' as const, p_value: 18 }, + }, + } + + beforeEach(() => { + findByQueryMock.mockResolvedValue([]) + }) + + afterEach(() => { + findByQueryMock.mockClear() + }) + + test('invalid referent', async () => { + await expect( + anonCredsHolderService.getCredentialsForProofRequest(agentContext, { + proofRequest, + attributeReferent: 'name', + }) + ).rejects.toThrowError() + }) + + test('referent with single restriction', async () => { + await anonCredsHolderService.getCredentialsForProofRequest(agentContext, { + proofRequest, + attributeReferent: 'attr1_referent', + }) + + expect(findByQueryMock).toHaveBeenCalledWith(agentContext, { + attributes: ['name'], + issuerId: 'issuer:uri', + }) + }) + + test('referent without restrictions', async () => { + await anonCredsHolderService.getCredentialsForProofRequest(agentContext, { + proofRequest, + attributeReferent: 'attr2_referent', + }) + + expect(findByQueryMock).toHaveBeenCalledWith(agentContext, { + attributes: ['phoneNumber'], + }) + }) + + test('referent with multiple, complex restrictions', async () => { + await anonCredsHolderService.getCredentialsForProofRequest(agentContext, { + proofRequest, + attributeReferent: 'attr3_referent', + }) + + expect(findByQueryMock).toHaveBeenCalledWith(agentContext, { + attributes: ['age'], + $or: [{ schemaId: 'schemaid:uri', schemaName: 'schemaName' }, { schemaVersion: '1.0' }], + }) + }) + + test('referent with multiple names and restrictions', async () => { + await anonCredsHolderService.getCredentialsForProofRequest(agentContext, { + proofRequest, + attributeReferent: 'attr4_referent', + }) + + expect(findByQueryMock).toHaveBeenCalledWith(agentContext, { + attributes: ['name', 'height'], + credentialDefinitionId: 'crededefid:uri', + issuerId: 'issuerid:uri', + }) + }) + + test('predicate referent', async () => { + await anonCredsHolderService.getCredentialsForProofRequest(agentContext, { + proofRequest, + attributeReferent: 'predicate1_referent', + }) + + expect(findByQueryMock).toHaveBeenCalledWith(agentContext, { + attributes: ['age'], + }) + }) + }) + + test('deleteCredential', async () => { + getByCredentialIdMock.mockRejectedValueOnce(new Error()) + getByCredentialIdMock.mockResolvedValueOnce( + new AnonCredsCredentialRecord({ + credential: {} as AnonCredsCredential, + credentialId: 'personCredId', + linkSecretId: 'linkSecretId', + issuerId: 'issuerDid', + schemaIssuerId: 'schemaIssuerDid', + schemaName: 'schemaName', + schemaVersion: 'schemaVersion', + }) + ) + + expect(anonCredsHolderService.deleteCredential(agentContext, 'credentialId')).rejects.toThrowError() + + await anonCredsHolderService.deleteCredential(agentContext, 'credentialId') + + expect(getByCredentialIdMock).toHaveBeenCalledWith(agentContext, 'credentialId') + }) + + test('getCredential', async () => { + getByCredentialIdMock.mockRejectedValueOnce(new Error()) + + getByCredentialIdMock.mockResolvedValueOnce( + new AnonCredsCredentialRecord({ + credential: { + cred_def_id: 'credDefId', + schema_id: 'schemaId', + signature: 'signature', + signature_correctness_proof: 'signatureCorrectnessProof', + values: { attr1: { raw: 'value1', encoded: 'encvalue1' }, attr2: { raw: 'value2', encoded: 'encvalue2' } }, + rev_reg_id: 'revRegId', + } as AnonCredsCredential, + credentialId: 'myCredentialId', + credentialRevocationId: 'credentialRevocationId', + linkSecretId: 'linkSecretId', + issuerId: 'issuerDid', + schemaIssuerId: 'schemaIssuerDid', + schemaName: 'schemaName', + schemaVersion: 'schemaVersion', + }) + ) + expect( + anonCredsHolderService.getCredential(agentContext, { credentialId: 'myCredentialId' }) + ).rejects.toThrowError() + + const credentialInfo = await anonCredsHolderService.getCredential(agentContext, { credentialId: 'myCredentialId' }) + + expect(credentialInfo).toMatchObject({ + attributes: { attr1: 'value1', attr2: 'value2' }, + credentialDefinitionId: 'credDefId', + credentialId: 'myCredentialId', + revocationRegistryId: 'revRegId', + schemaId: 'schemaId', + credentialRevocationId: 'credentialRevocationId', + }) + }) + + test('storeCredential', async () => { + const { credentialDefinition, credentialDefinitionPrivate, keyCorrectnessProof } = createCredentialDefinition({ + attributeNames: ['name', 'age', 'sex', 'height'], + issuerId: 'issuer:uri', + }) + + const linkSecret = createLinkSecret() + + mockFunction(anoncredsLinkSecretRepositoryMock.getByLinkSecretId).mockResolvedValue( + new AnonCredsLinkSecretRecord({ linkSecretId: 'linkSecretId', value: linkSecret }) + ) + + const schema: AnonCredsSchema = { + attrNames: ['name', 'sex', 'height', 'age'], + issuerId: 'issuerId', + name: 'schemaName', + version: '1', + } + + const { credential, revocationRegistryDefinition, credentialRequestMetadata } = createCredentialForHolder({ + attributes: { + name: 'John', + sex: 'M', + height: '179', + age: '19', + }, + credentialDefinition, + schemaId: 'personschema:uri', + credentialDefinitionId: 'personcreddef:uri', + credentialDefinitionPrivate, + keyCorrectnessProof, + linkSecret, + linkSecretId: 'linkSecretId', + credentialId: 'personCredId', + revocationRegistryDefinitionId: 'personrevregid:uri', + }) + + const saveCredentialMock = jest.spyOn(anoncredsCredentialRepositoryMock, 'save') + + saveCredentialMock.mockResolvedValue() + + const credentialId = await anonCredsHolderService.storeCredential(agentContext, { + credential, + credentialDefinition, + schema, + credentialDefinitionId: 'personcreddefid:uri', + credentialRequestMetadata: JSON.parse(credentialRequestMetadata.toJson()), + credentialId: 'personCredId', + revocationRegistry: { + id: 'personrevregid:uri', + definition: JSON.parse(new RevocationRegistryDefinition(revocationRegistryDefinition.handle).toJson()), + }, + }) + + expect(credentialId).toBe('personCredId') + expect(saveCredentialMock).toHaveBeenCalledWith( + agentContext, + expect.objectContaining({ + // The stored credential is different from the one received originally + credentialId: 'personCredId', + linkSecretId: 'linkSecretId', + _tags: expect.objectContaining({ + issuerId: credentialDefinition.issuerId, + schemaName: 'schemaName', + schemaIssuerId: 'issuerId', + schemaVersion: '1', + }), + }) + ) + }) +}) diff --git a/packages/anoncreds-rs/src/services/__tests__/AnonCredsRsServices.test.ts b/packages/anoncreds-rs/src/services/__tests__/AnonCredsRsServices.test.ts new file mode 100644 index 0000000000..3e23f27eb0 --- /dev/null +++ b/packages/anoncreds-rs/src/services/__tests__/AnonCredsRsServices.test.ts @@ -0,0 +1,224 @@ +import type { AnonCredsProofRequest } from '@aries-framework/anoncreds' + +import { + AnonCredsHolderServiceSymbol, + AnonCredsIssuerServiceSymbol, + AnonCredsVerifierServiceSymbol, + AnonCredsSchemaRepository, + AnonCredsSchemaRecord, + AnonCredsCredentialDefinitionRecord, + AnonCredsCredentialDefinitionRepository, + AnonCredsCredentialDefinitionPrivateRecord, + AnonCredsCredentialDefinitionPrivateRepository, + AnonCredsKeyCorrectnessProofRepository, + AnonCredsKeyCorrectnessProofRecord, + AnonCredsLinkSecretRepository, + AnonCredsLinkSecretRecord, +} from '@aries-framework/anoncreds' +import { InjectionSymbols } from '@aries-framework/core' +import { anoncreds } from '@hyperledger/anoncreds-nodejs' +import { Subject } from 'rxjs' + +import { InMemoryStorageService } from '../../../../../tests/InMemoryStorageService' +import { encode } from '../../../../anoncreds/src/utils/credential' +import { InMemoryAnonCredsRegistry } from '../../../../anoncreds/tests/InMemoryAnonCredsRegistry' +import { agentDependencies, getAgentConfig, getAgentContext } from '../../../../core/tests/helpers' +import { AnonCredsRsHolderService } from '../AnonCredsRsHolderService' +import { AnonCredsRsIssuerService } from '../AnonCredsRsIssuerService' +import { AnonCredsRsVerifierService } from '../AnonCredsRsVerifierService' + +const agentConfig = getAgentConfig('AnonCredsCredentialFormatServiceTest') +const anonCredsVerifierService = new AnonCredsRsVerifierService() +const anonCredsHolderService = new AnonCredsRsHolderService() +const anonCredsIssuerService = new AnonCredsRsIssuerService() +const storageService = new InMemoryStorageService() +const registry = new InMemoryAnonCredsRegistry() + +const agentContext = getAgentContext({ + registerInstances: [ + [InjectionSymbols.Stop$, new Subject()], + [InjectionSymbols.AgentDependencies, agentDependencies], + [InjectionSymbols.StorageService, storageService], + [AnonCredsIssuerServiceSymbol, anonCredsIssuerService], + [AnonCredsHolderServiceSymbol, anonCredsHolderService], + [AnonCredsVerifierServiceSymbol, anonCredsVerifierService], + ], + agentConfig, +}) + +describe('AnonCredsRsServices', () => { + test('issuance flow without revocation', async () => { + const issuerId = 'issuer:uri' + + const schema = await anonCredsIssuerService.createSchema(agentContext, { + attrNames: ['name', 'age'], + issuerId, + name: 'Employee Credential', + version: '1.0.0', + }) + + const { schemaState, schemaMetadata } = await registry.registerSchema(agentContext, { + schema, + options: {}, + }) + + const { credentialDefinition, credentialDefinitionPrivate, keyCorrectnessProof } = + await anonCredsIssuerService.createCredentialDefinition(agentContext, { + issuerId, + schemaId: schemaState.schemaId as string, + schema, + tag: 'Employee Credential', + supportRevocation: false, + }) + + const { credentialDefinitionState } = await registry.registerCredentialDefinition(agentContext, { + credentialDefinition, + options: {}, + }) + + if ( + !credentialDefinitionState.credentialDefinition || + !credentialDefinitionState.credentialDefinitionId || + !schemaState.schema || + !schemaState.schemaId + ) { + throw new Error('Failed to create schema or credential definition') + } + + if (!credentialDefinitionPrivate || !keyCorrectnessProof) { + throw new Error('Failed to get private part of credential definition') + } + + await agentContext.dependencyManager.resolve(AnonCredsSchemaRepository).save( + agentContext, + new AnonCredsSchemaRecord({ + schema: schemaState.schema, + schemaId: schemaState.schemaId, + }) + ) + + await agentContext.dependencyManager.resolve(AnonCredsCredentialDefinitionRepository).save( + agentContext, + new AnonCredsCredentialDefinitionRecord({ + credentialDefinition: credentialDefinitionState.credentialDefinition, + credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, + }) + ) + + 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 credentialOffer = await anonCredsIssuerService.createCredentialOffer(agentContext, { + credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, + }) + + const linkSecret = await anonCredsHolderService.createLinkSecret(agentContext, { linkSecretId: 'linkSecretId' }) + expect(linkSecret.linkSecretId).toBe('linkSecretId') + + await agentContext.dependencyManager.resolve(AnonCredsLinkSecretRepository).save( + agentContext, + new AnonCredsLinkSecretRecord({ + value: linkSecret.linkSecretValue, + linkSecretId: linkSecret.linkSecretId, + }) + ) + + const credentialRequestState = await anonCredsHolderService.createCredentialRequest(agentContext, { + credentialDefinition: credentialDefinitionState.credentialDefinition, + credentialOffer, + linkSecretId: linkSecret.linkSecretId, + }) + + const { credential } = await anonCredsIssuerService.createCredential(agentContext, { + credentialOffer, + credentialRequest: credentialRequestState.credentialRequest, + credentialValues: { name: { raw: 'John', encoded: encode('John') }, age: { raw: '25', encoded: encode('25') } }, + }) + + const credentialId = 'holderCredentialId' + + const storedId = await anonCredsHolderService.storeCredential(agentContext, { + credential, + credentialDefinition, + schema, + credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, + credentialRequestMetadata: credentialRequestState.credentialRequestMetadata, + credentialId, + }) + + expect(storedId).toEqual(credentialId) + + const credentialInfo = await anonCredsHolderService.getCredential(agentContext, { + credentialId, + }) + + expect(credentialInfo).toEqual({ + credentialId, + attributes: { + age: '25', + name: 'John', + }, + schemaId: schemaState.schemaId, + credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, + revocationRegistryId: null, + credentialRevocationId: undefined, // Should it be null in this case? + }) + + const proofRequest: AnonCredsProofRequest = { + nonce: anoncreds.generateNonce(), + name: 'pres_req_1', + version: '0.1', + requested_attributes: { + attr1_referent: { + name: 'name', + }, + attr2_referent: { + name: 'age', + }, + }, + requested_predicates: { + predicate1_referent: { name: 'age', p_type: '>=' as const, p_value: 18 }, + }, + } + + const proof = await anonCredsHolderService.createProof(agentContext, { + credentialDefinitions: { [credentialDefinitionState.credentialDefinitionId]: credentialDefinition }, + proofRequest, + requestedCredentials: { + requestedAttributes: { + attr1_referent: { credentialId, credentialInfo, revealed: true }, + attr2_referent: { credentialId, credentialInfo, revealed: true }, + }, + requestedPredicates: { + predicate1_referent: { credentialId, credentialInfo }, + }, + selfAttestedAttributes: {}, + }, + schemas: { [schemaState.schemaId]: schema }, + revocationRegistries: {}, + }) + + const verifiedProof = await anonCredsVerifierService.verifyProof({ + credentialDefinitions: { [credentialDefinitionState.credentialDefinitionId]: credentialDefinition }, + proof, + proofRequest, + schemas: { [schemaState.schemaId]: schema }, + revocationStates: {}, + }) + + expect(verifiedProof).toBeTruthy() + }) +}) diff --git a/packages/anoncreds-rs/src/services/__tests__/helpers.ts b/packages/anoncreds-rs/src/services/__tests__/helpers.ts new file mode 100644 index 0000000000..07d5b09f49 --- /dev/null +++ b/packages/anoncreds-rs/src/services/__tests__/helpers.ts @@ -0,0 +1,173 @@ +import type { AnonCredsCredentialInfo } from '@aries-framework/anoncreds' + +import { + anoncreds, + CredentialDefinition, + CredentialDefinitionPrivate, + CredentialOffer, + CredentialRequest, + KeyCorrectnessProof, + MasterSecret, + Schema, +} from '@hyperledger/anoncreds-shared' + +/** + * Creates a valid credential definition and returns its public and + * private part, including its key correctness proof + */ +export function createCredentialDefinition(options: { attributeNames: string[]; issuerId: string }) { + const { attributeNames, issuerId } = options + + const schema = Schema.create({ + issuerId, + attributeNames, + name: 'schema1', + version: '1', + }) + + const { credentialDefinition, credentialDefinitionPrivate, keyCorrectnessProof } = CredentialDefinition.create({ + issuerId, + schema, + schemaId: 'schema:uri', + signatureType: 'CL', + supportRevocation: true, // FIXME: Revocation should not be mandatory but current anoncreds-rs is requiring it + tag: 'TAG', + }) + + return { + credentialDefinition: JSON.parse(credentialDefinition.toJson()), + credentialDefinitionPrivate: JSON.parse(credentialDefinitionPrivate.toJson()), + keyCorrectnessProof: JSON.parse(keyCorrectnessProof.toJson()), + schema: JSON.parse(schema.toJson()), + } +} + +/** + * Creates a valid credential offer and returns itsf + */ +export function createCredentialOffer(kcp: Record) { + const credentialOffer = CredentialOffer.create({ + credentialDefinitionId: 'creddef:uri', + keyCorrectnessProof: KeyCorrectnessProof.load(JSON.stringify(kcp)), + schemaId: 'schema:uri', + }) + return JSON.parse(credentialOffer.toJson()) +} + +/** + * + * @returns Creates a valid link secret value for anoncreds-rs + */ +export function createLinkSecret() { + return JSON.parse(MasterSecret.create().toJson()).value.ms as string +} + +export function createCredentialForHolder(options: { + credentialDefinition: Record + credentialDefinitionPrivate: Record + keyCorrectnessProof: Record + schemaId: string + credentialDefinitionId: string + attributes: Record + linkSecret: string + linkSecretId: string + credentialId: string + revocationRegistryDefinitionId: string +}) { + const { + credentialDefinition, + credentialDefinitionPrivate, + keyCorrectnessProof, + schemaId, + credentialDefinitionId, + attributes, + linkSecret, + linkSecretId, + credentialId, + revocationRegistryDefinitionId, + } = options + + const credentialOffer = CredentialOffer.create({ + credentialDefinitionId, + keyCorrectnessProof: KeyCorrectnessProof.load(JSON.stringify(keyCorrectnessProof)), + schemaId, + }) + + const { credentialRequest, credentialRequestMetadata } = CredentialRequest.create({ + credentialDefinition: CredentialDefinition.load(JSON.stringify(credentialDefinition)), + credentialOffer, + masterSecret: MasterSecret.load(JSON.stringify({ value: { ms: linkSecret } })), + masterSecretId: linkSecretId, + }) + + // FIXME: Revocation config should not be mandatory but current anoncreds-rs is requiring it + + const { revocationRegistryDefinition, revocationRegistryDefinitionPrivate, tailsPath } = + createRevocationRegistryDefinition({ + credentialDefinitionId, + credentialDefinition, + }) + + const timeCreateRevStatusList = 12 + const revocationStatusList = anoncreds.createRevocationStatusList({ + timestamp: timeCreateRevStatusList, + issuanceByDefault: true, + revocationRegistryDefinition, + revocationRegistryDefinitionId: revocationRegistryDefinitionId, + }) + + // TODO: Use Credential.create (needs to update the paramters in anoncreds-rs) + const credentialObj = anoncreds.createCredential({ + credentialDefinition: CredentialDefinition.load(JSON.stringify(credentialDefinition)).handle, + credentialDefinitionPrivate: CredentialDefinitionPrivate.load(JSON.stringify(credentialDefinitionPrivate)).handle, + credentialOffer: credentialOffer.handle, + credentialRequest: credentialRequest.handle, + attributeRawValues: attributes, + revocationRegistryId: revocationRegistryDefinitionId, + revocationStatusList, + revocationConfiguration: { + registryIndex: 9, + revocationRegistryDefinition, + revocationRegistryDefinitionPrivate, + tailsPath, + }, + }) + const credential = anoncreds.getJson({ objectHandle: credentialObj }) + + const credentialInfo: AnonCredsCredentialInfo = { + attributes, + credentialDefinitionId, + credentialId, + schemaId, + } + return { + credential: JSON.parse(credential), + credentialInfo, + revocationRegistryDefinition, + tailsPath, + credentialRequestMetadata, + } +} + +export function createRevocationRegistryDefinition(options: { + credentialDefinitionId: string + credentialDefinition: Record +}) { + const { credentialDefinitionId, credentialDefinition } = options + const { revocationRegistryDefinition, revocationRegistryDefinitionPrivate } = + anoncreds.createRevocationRegistryDefinition({ + credentialDefinitionId, + credentialDefinition: CredentialDefinition.load(JSON.stringify(credentialDefinition)).handle, + issuerId: 'mock:uri', + tag: 'some_tag', + revocationRegistryType: 'CL_ACCUM', + maximumCredentialNumber: 10, + }) + + const tailsPath = anoncreds.revocationRegistryDefinitionGetAttribute({ + objectHandle: revocationRegistryDefinition, + name: 'tails_location', + }) + + return { revocationRegistryDefinition, revocationRegistryDefinitionPrivate, tailsPath } +} diff --git a/packages/anoncreds-rs/src/services/index.ts b/packages/anoncreds-rs/src/services/index.ts new file mode 100644 index 0000000000..b675ab0025 --- /dev/null +++ b/packages/anoncreds-rs/src/services/index.ts @@ -0,0 +1,3 @@ +export { AnonCredsRsHolderService } from './AnonCredsRsHolderService' +export { AnonCredsRsIssuerService } from './AnonCredsRsIssuerService' +export { AnonCredsRsVerifierService } from './AnonCredsRsVerifierService' diff --git a/packages/anoncreds-rs/src/types.ts b/packages/anoncreds-rs/src/types.ts new file mode 100644 index 0000000000..2694976be7 --- /dev/null +++ b/packages/anoncreds-rs/src/types.ts @@ -0,0 +1,4 @@ +import type { Anoncreds } from '@hyperledger/anoncreds-shared' + +export const AnonCredsRsSymbol = Symbol('AnonCredsRs') +export type { Anoncreds } diff --git a/packages/anoncreds-rs/tests/indy-flow.test.ts b/packages/anoncreds-rs/tests/indy-flow.test.ts new file mode 100644 index 0000000000..fc2ce9ec87 --- /dev/null +++ b/packages/anoncreds-rs/tests/indy-flow.test.ts @@ -0,0 +1,277 @@ +import { + AnonCredsModuleConfig, + LegacyIndyCredentialFormatService, + AnonCredsHolderServiceSymbol, + AnonCredsIssuerServiceSymbol, + AnonCredsVerifierServiceSymbol, + AnonCredsRegistryService, + AnonCredsSchemaRecord, + AnonCredsSchemaRepository, + AnonCredsCredentialDefinitionRepository, + AnonCredsCredentialDefinitionRecord, + AnonCredsCredentialDefinitionPrivateRepository, + AnonCredsCredentialDefinitionPrivateRecord, + AnonCredsKeyCorrectnessProofRepository, + AnonCredsKeyCorrectnessProofRecord, + AnonCredsLinkSecretRepository, + AnonCredsLinkSecretRecord, +} from '@aries-framework/anoncreds' +import { + CredentialState, + CredentialExchangeRecord, + CredentialPreviewAttribute, + InjectionSymbols, +} from '@aries-framework/core' +import { Subject } from 'rxjs' + +import { InMemoryStorageService } from '../../../tests/InMemoryStorageService' +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' + +const registry = new InMemoryAnonCredsRegistry() +const anonCredsModuleConfig = new AnonCredsModuleConfig({ + registries: [registry], +}) + +const agentConfig = getAgentConfig('LegacyIndyCredentialFormatService') +const anonCredsVerifierService = new AnonCredsRsVerifierService() +const anonCredsHolderService = new AnonCredsRsHolderService() +const anonCredsIssuerService = new AnonCredsRsIssuerService() + +const inMemoryStorageService = new InMemoryStorageService() +const agentContext = getAgentContext({ + registerInstances: [ + [InjectionSymbols.Stop$, new Subject()], + [InjectionSymbols.AgentDependencies, agentDependencies], + [InjectionSymbols.StorageService, inMemoryStorageService], + [AnonCredsIssuerServiceSymbol, anonCredsIssuerService], + [AnonCredsHolderServiceSymbol, anonCredsHolderService], + [AnonCredsVerifierServiceSymbol, anonCredsVerifierService], + [AnonCredsRegistryService, new AnonCredsRegistryService()], + [AnonCredsModuleConfig, anonCredsModuleConfig], + ], + agentConfig, +}) + +const legacyIndyCredentialFormatService = new LegacyIndyCredentialFormatService() + +describe('LegacyIndyCredentialFormatService using anoncreds-rs', () => { + test('issuance flow starting from proposal without negotiation and without revocation', async () => { + // This is just so we don't have to register an actually indy did (as we don't have the indy did registrar configured) + const indyDid = 'TL1EaPFCZ8Si5aUrqScBDt' + + const schema = await anonCredsIssuerService.createSchema(agentContext, { + attrNames: ['name', 'age'], + issuerId: indyDid, + name: 'Employee Credential', + version: '1.0.0', + }) + + const { schemaState, schemaMetadata } = await registry.registerSchema(agentContext, { + schema, + options: {}, + }) + + const { credentialDefinition, credentialDefinitionPrivate, keyCorrectnessProof } = + await anonCredsIssuerService.createCredentialDefinition(agentContext, { + issuerId: indyDid, + schemaId: schemaState.schemaId as string, + schema, + tag: 'Employee Credential', + supportRevocation: false, + }) + + const { credentialDefinitionState } = await registry.registerCredentialDefinition(agentContext, { + credentialDefinition, + options: {}, + }) + + if ( + !credentialDefinitionState.credentialDefinition || + !credentialDefinitionState.credentialDefinitionId || + !schemaState.schema || + !schemaState.schemaId + ) { + throw new Error('Failed to create schema or credential definition') + } + + if ( + !credentialDefinitionState.credentialDefinition || + !credentialDefinitionState.credentialDefinitionId || + !schemaState.schema || + !schemaState.schemaId + ) { + throw new Error('Failed to create schema or credential definition') + } + + if (!credentialDefinitionPrivate || !keyCorrectnessProof) { + throw new Error('Failed to get private part of credential definition') + } + + await agentContext.dependencyManager.resolve(AnonCredsSchemaRepository).save( + agentContext, + new AnonCredsSchemaRecord({ + schema: schemaState.schema, + schemaId: schemaState.schemaId, + }) + ) + + await agentContext.dependencyManager.resolve(AnonCredsCredentialDefinitionRepository).save( + agentContext, + new AnonCredsCredentialDefinitionRecord({ + credentialDefinition: credentialDefinitionState.credentialDefinition, + credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, + }) + ) + + 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 linkSecret = await anonCredsHolderService.createLinkSecret(agentContext, { linkSecretId: 'linkSecretId' }) + expect(linkSecret.linkSecretId).toBe('linkSecretId') + + await agentContext.dependencyManager.resolve(AnonCredsLinkSecretRepository).save( + agentContext, + new AnonCredsLinkSecretRecord({ + value: linkSecret.linkSecretValue, + linkSecretId: linkSecret.linkSecretId, + }) + ) + + 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 legacyIndyCredentialFormatService.createProposal(agentContext, { + credentialRecord: holderCredentialRecord, + credentialFormats: { + indy: { + attributes: credentialAttributes, + credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, + }, + }, + }) + + // Issuer processes and accepts proposal + await legacyIndyCredentialFormatService.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 legacyIndyCredentialFormatService.acceptProposal(agentContext, { + credentialRecord: issuerCredentialRecord, + proposalAttachment: proposalAttachment, + }) + + // Holder processes and accepts offer + await legacyIndyCredentialFormatService.processOffer(agentContext, { + credentialRecord: holderCredentialRecord, + attachment: offerAttachment, + }) + const { attachment: requestAttachment } = await legacyIndyCredentialFormatService.acceptOffer(agentContext, { + credentialRecord: holderCredentialRecord, + offerAttachment, + credentialFormats: { + indy: { + linkSecretId: linkSecret.linkSecretId, + }, + }, + }) + + // Issuer processes and accepts request + await legacyIndyCredentialFormatService.processRequest(agentContext, { + credentialRecord: issuerCredentialRecord, + attachment: requestAttachment, + }) + const { attachment: credentialAttachment } = await legacyIndyCredentialFormatService.acceptRequest(agentContext, { + credentialRecord: issuerCredentialRecord, + requestAttachment, + offerAttachment, + }) + + // Holder processes and accepts credential + await legacyIndyCredentialFormatService.processCredential(agentContext, { + credentialRecord: holderCredentialRecord, + attachment: credentialAttachment, + requestAttachment, + }) + + expect(holderCredentialRecord.credentials).toEqual([ + { credentialRecordType: 'anoncreds', credentialRecordId: expect.any(String) }, + ]) + + 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? + }) + + expect(holderCredentialRecord.metadata.data).toEqual({ + '_anonCreds/anonCredsCredential': { + schemaId: schemaState.schemaId, + credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, + }, + '_anonCreds/anonCredsCredentialRequest': { + master_secret_blinding_data: expect.any(Object), + master_secret_name: expect.any(String), + nonce: expect.any(String), + }, + }) + + expect(issuerCredentialRecord.metadata.data).toEqual({ + '_anonCreds/anonCredsCredential': { + schemaId: schemaState.schemaId, + credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, + }, + }) + }) +}) diff --git a/packages/anoncreds-rs/tests/setup.ts b/packages/anoncreds-rs/tests/setup.ts new file mode 100644 index 0000000000..a5fef0aec8 --- /dev/null +++ b/packages/anoncreds-rs/tests/setup.ts @@ -0,0 +1,3 @@ +import '@hyperledger/anoncreds-nodejs' + +jest.setTimeout(60000) diff --git a/packages/anoncreds-rs/tsconfig.build.json b/packages/anoncreds-rs/tsconfig.build.json new file mode 100644 index 0000000000..2b75d0adab --- /dev/null +++ b/packages/anoncreds-rs/tsconfig.build.json @@ -0,0 +1,7 @@ +{ + "extends": "../../tsconfig.build.json", + "compilerOptions": { + "outDir": "./build" + }, + "include": ["src/**/*"] +} diff --git a/packages/anoncreds-rs/tsconfig.json b/packages/anoncreds-rs/tsconfig.json new file mode 100644 index 0000000000..46efe6f721 --- /dev/null +++ b/packages/anoncreds-rs/tsconfig.json @@ -0,0 +1,6 @@ +{ + "extends": "../../tsconfig.json", + "compilerOptions": { + "types": ["jest"] + } +} diff --git a/packages/anoncreds/src/formats/AnonCredsCredentialFormat.ts b/packages/anoncreds/src/formats/AnonCredsCredentialFormat.ts index fd6ebf7fcb..e08109f56f 100644 --- a/packages/anoncreds/src/formats/AnonCredsCredentialFormat.ts +++ b/packages/anoncreds/src/formats/AnonCredsCredentialFormat.ts @@ -35,7 +35,9 @@ export interface AnonCredsAcceptProposalFormat { * This defines the module payload for calling CredentialsApi.acceptOffer. No options are available for this * method, so it's an empty object */ -export type AnonCredsAcceptOfferFormat = Record +export interface AnonCredsAcceptOfferFormat { + linkSecretId?: string +} /** * This defines the module payload for calling CredentialsApi.offerCredential diff --git a/packages/anoncreds/src/formats/LegacyIndyCredentialFormatService.ts b/packages/anoncreds/src/formats/LegacyIndyCredentialFormatService.ts index e1fd945937..6be55555a4 100644 --- a/packages/anoncreds/src/formats/LegacyIndyCredentialFormatService.ts +++ b/packages/anoncreds/src/formats/LegacyIndyCredentialFormatService.ts @@ -209,7 +209,12 @@ export class LegacyIndyCredentialFormatService implements CredentialFormatServic public async acceptOffer( agentContext: AgentContext, - { credentialRecord, attachId, offerAttachment }: FormatAcceptOfferOptions + { + credentialRecord, + attachId, + offerAttachment, + credentialFormats, + }: FormatAcceptOfferOptions ): Promise { const registryService = agentContext.dependencyManager.resolve(AnonCredsRegistryService) const holderService = agentContext.dependencyManager.resolve(AnonCredsHolderServiceSymbol) @@ -232,6 +237,7 @@ export class LegacyIndyCredentialFormatService implements CredentialFormatServic const { credentialRequest, credentialRequestMetadata } = await holderService.createCredentialRequest(agentContext, { credentialOffer, credentialDefinition, + linkSecretId: credentialFormats?.indy?.linkSecretId, }) credentialRecord.metadata.set( @@ -357,6 +363,15 @@ export class LegacyIndyCredentialFormatService implements CredentialFormatServic ) } + const schemaResult = await registryService + .getRegistryForIdentifier(agentContext, anonCredsCredential.cred_def_id) + .getSchema(agentContext, anonCredsCredential.schema_id) + if (!schemaResult.schema) { + throw new AriesFrameworkError( + `Unable to resolve schema ${anonCredsCredential.schema_id}: ${schemaResult.resolutionMetadata.error} ${schemaResult.resolutionMetadata.message}` + ) + } + // Resolve revocation registry if credential is revocable let revocationRegistryResult: null | GetRevocationRegistryDefinitionReturn = null if (anonCredsCredential.rev_reg_id) { @@ -381,6 +396,7 @@ export class LegacyIndyCredentialFormatService implements CredentialFormatServic credential: anonCredsCredential, credentialDefinitionId: credentialDefinitionResult.credentialDefinitionId, credentialDefinition: credentialDefinitionResult.credentialDefinition, + schema: schemaResult.schema, revocationRegistry: revocationRegistryResult?.revocationRegistryDefinition ? { definition: revocationRegistryResult.revocationRegistryDefinition, diff --git a/packages/anoncreds/src/models/registry.ts b/packages/anoncreds/src/models/registry.ts index f4f3429ec2..31314ada51 100644 --- a/packages/anoncreds/src/models/registry.ts +++ b/packages/anoncreds/src/models/registry.ts @@ -19,17 +19,19 @@ export interface AnonCredsCredentialDefinition { export interface AnonCredsRevocationRegistryDefinition { issuerId: string - type: 'CL_ACCUM' + revocDefType: 'CL_ACCUM' credDefId: string tag: string - publicKeys: { - accumKey: { - z: string + value: { + publicKeys: { + accumKey: { + z: string + } } + maxCredNum: number + tailsLocation: string + tailsHash: string } - maxCredNum: number - tailsLocation: string - tailsHash: string } export interface AnonCredsRevocationStatusList { diff --git a/packages/anoncreds/src/repository/AnonCredsCredentialRecord.ts b/packages/anoncreds/src/repository/AnonCredsCredentialRecord.ts new file mode 100644 index 0000000000..3d4d0958b7 --- /dev/null +++ b/packages/anoncreds/src/repository/AnonCredsCredentialRecord.ts @@ -0,0 +1,76 @@ +import type { AnonCredsCredential } from '../models' + +import { BaseRecord, utils } from '@aries-framework/core' + +export interface AnonCredsCredentialRecordProps { + id?: string + credential: AnonCredsCredential + credentialId: string + credentialRevocationId?: string + linkSecretId: string + schemaName: string + schemaVersion: string + schemaIssuerId: string + issuerId: string +} + +export type DefaultAnonCredsCredentialTags = { + credentialId: string + linkSecretId: string + credentialDefinitionId: string + credentialRevocationId?: string + revocationRegistryId?: string + schemaId: string + attributes: string[] +} + +export type CustomAnonCredsCredentialTags = { + schemaName: string + schemaVersion: string + schemaIssuerId: string + issuerId: string +} + +export class AnonCredsCredentialRecord extends BaseRecord< + DefaultAnonCredsCredentialTags, + CustomAnonCredsCredentialTags +> { + public static readonly type = 'AnonCredsCredentialRecord' + public readonly type = AnonCredsCredentialRecord.type + + public readonly credentialId!: string + public readonly credentialRevocationId?: string + public readonly linkSecretId!: string + public readonly credential!: AnonCredsCredential + + public constructor(props: AnonCredsCredentialRecordProps) { + super() + + if (props) { + this.id = props.id ?? utils.uuid() + this.credentialId = props.credentialId + this.credential = props.credential + this.credentialRevocationId = props.credentialRevocationId + this.linkSecretId = props.linkSecretId + this.setTags({ + issuerId: props.issuerId, + schemaIssuerId: props.schemaIssuerId, + schemaName: props.schemaName, + schemaVersion: props.schemaVersion, + }) + } + } + + public getTags() { + return { + ...this._tags, + credentialDefinitionId: this.credential.cred_def_id, + schemaId: this.credential.schema_id, + credentialId: this.credentialId, + credentialRevocationId: this.credentialRevocationId, + revocationRegistryId: this.credential.rev_reg_id, + linkSecretId: this.linkSecretId, + attributes: Object.keys(this.credential.values), + } + } +} diff --git a/packages/anoncreds/src/repository/AnonCredsCredentialRepository.ts b/packages/anoncreds/src/repository/AnonCredsCredentialRepository.ts new file mode 100644 index 0000000000..fb02878439 --- /dev/null +++ b/packages/anoncreds/src/repository/AnonCredsCredentialRepository.ts @@ -0,0 +1,31 @@ +import type { AgentContext } from '@aries-framework/core' + +import { Repository, InjectionSymbols, StorageService, EventEmitter, injectable, inject } from '@aries-framework/core' + +import { AnonCredsCredentialRecord } from './AnonCredsCredentialRecord' + +@injectable() +export class AnonCredsCredentialRepository extends Repository { + public constructor( + @inject(InjectionSymbols.StorageService) storageService: StorageService, + eventEmitter: EventEmitter + ) { + super(AnonCredsCredentialRecord, storageService, eventEmitter) + } + + public async getByCredentialDefinitionId(agentContext: AgentContext, credentialDefinitionId: string) { + return this.getSingleByQuery(agentContext, { credentialDefinitionId }) + } + + public async findByCredentialDefinitionId(agentContext: AgentContext, credentialDefinitionId: string) { + return this.findSingleByQuery(agentContext, { credentialDefinitionId }) + } + + public async getByCredentialId(agentContext: AgentContext, credentialId: string) { + return this.getSingleByQuery(agentContext, { credentialId }) + } + + public async findByCredentialId(agentContext: AgentContext, credentialId: string) { + return this.findSingleByQuery(agentContext, { credentialId }) + } +} diff --git a/packages/anoncreds/src/repository/index.ts b/packages/anoncreds/src/repository/index.ts index 5e17e19941..c4fb3bbe80 100644 --- a/packages/anoncreds/src/repository/index.ts +++ b/packages/anoncreds/src/repository/index.ts @@ -1,3 +1,5 @@ +export * from './AnonCredsCredentialRecord' +export * from './AnonCredsCredentialRepository' export * from './AnonCredsCredentialDefinitionRecord' export * from './AnonCredsCredentialDefinitionRepository' export * from './AnonCredsCredentialDefinitionPrivateRecord' diff --git a/packages/anoncreds/src/services/AnonCredsHolderServiceOptions.ts b/packages/anoncreds/src/services/AnonCredsHolderServiceOptions.ts index fcbc5e913c..747e3fcfed 100644 --- a/packages/anoncreds/src/services/AnonCredsHolderServiceOptions.ts +++ b/packages/anoncreds/src/services/AnonCredsHolderServiceOptions.ts @@ -47,6 +47,7 @@ export interface StoreCredentialOptions { credentialRequestMetadata: AnonCredsCredentialRequestMetadata credential: AnonCredsCredential credentialDefinition: AnonCredsCredentialDefinition + schema: AnonCredsSchema credentialDefinitionId: string credentialId?: string revocationRegistry?: { diff --git a/packages/anoncreds/tests/anoncreds.test.ts b/packages/anoncreds/tests/anoncreds.test.ts index e7abd466c4..f905c92db9 100644 --- a/packages/anoncreds/tests/anoncreds.test.ts +++ b/packages/anoncreds/tests/anoncreds.test.ts @@ -43,16 +43,18 @@ const existingRevocationRegistryDefinitions = { 'VsKV7grR1BUE29mG2Fm2kX:4:VsKV7grR1BUE29mG2Fm2kX:3:CL:75206:TAG:CL_ACCUM:TAG': { credDefId: 'VsKV7grR1BUE29mG2Fm2kX:3:CL:75206:TAG', issuerId: 'VsKV7grR1BUE29mG2Fm2kX', - maxCredNum: 100, - type: 'CL_ACCUM', - publicKeys: { - accumKey: { - z: 'ab81257c-be63-4051-9e21-c7d384412f64', + revocDefType: 'CL_ACCUM', + value: { + publicKeys: { + accumKey: { + z: 'ab81257c-be63-4051-9e21-c7d384412f64', + }, }, + maxCredNum: 100, + tailsHash: 'ab81257c-be63-4051-9e21-c7d384412f64', + tailsLocation: 'http://localhost:7200/tails', }, tag: 'TAG', - tailsHash: 'ab81257c-be63-4051-9e21-c7d384412f64', - tailsLocation: 'http://localhost:7200/tails', }, } as const diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index ee9c82dfa0..91d22659c4 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -33,7 +33,7 @@ export * from './storage/BaseRecord' export { InMemoryMessageRepository } from './storage/InMemoryMessageRepository' export { Repository } from './storage/Repository' export * from './storage/RepositoryEvents' -export { StorageService, Query, BaseRecordConstructor } from './storage/StorageService' +export { StorageService, Query, SimpleQuery, BaseRecordConstructor } from './storage/StorageService' export * from './storage/migration' export { getDirFromFilePath } from './utils/path' export { InjectionSymbols } from './constants' diff --git a/packages/indy-sdk/src/anoncreds/utils/transform.ts b/packages/indy-sdk/src/anoncreds/utils/transform.ts index 6a91928f70..e976d514e4 100644 --- a/packages/indy-sdk/src/anoncreds/utils/transform.ts +++ b/packages/indy-sdk/src/anoncreds/utils/transform.ts @@ -63,12 +63,14 @@ export function anonCredsRevocationRegistryDefinitionFromIndySdk( return { issuerId, credDefId: revocationRegistryDefinition.credDefId, - maxCredNum: revocationRegistryDefinition.value.maxCredNum, - publicKeys: revocationRegistryDefinition.value.publicKeys, + value: { + maxCredNum: revocationRegistryDefinition.value.maxCredNum, + publicKeys: revocationRegistryDefinition.value.publicKeys, + tailsHash: revocationRegistryDefinition.value.tailsHash, + tailsLocation: revocationRegistryDefinition.value.tailsLocation, + }, tag: revocationRegistryDefinition.tag, - tailsHash: revocationRegistryDefinition.value.tailsHash, - tailsLocation: revocationRegistryDefinition.value.tailsLocation, - type: 'CL_ACCUM', + revocDefType: 'CL_ACCUM', } } @@ -79,14 +81,14 @@ export function indySdkRevocationRegistryDefinitionFromAnonCreds( return { id: revocationRegistryDefinitionId, credDefId: revocationRegistryDefinition.credDefId, - revocDefType: revocationRegistryDefinition.type, + revocDefType: revocationRegistryDefinition.revocDefType, tag: revocationRegistryDefinition.tag, value: { issuanceType: 'ISSUANCE_BY_DEFAULT', // NOTE: we always use ISSUANCE_BY_DEFAULT when passing to the indy-sdk. It doesn't matter, as we have the revocation List with the full state - maxCredNum: revocationRegistryDefinition.maxCredNum, - publicKeys: revocationRegistryDefinition.publicKeys, - tailsHash: revocationRegistryDefinition.tailsHash, - tailsLocation: revocationRegistryDefinition.tailsLocation, + maxCredNum: revocationRegistryDefinition.value.maxCredNum, + publicKeys: revocationRegistryDefinition.value.publicKeys, + tailsHash: revocationRegistryDefinition.value.tailsHash, + tailsLocation: revocationRegistryDefinition.value.tailsLocation, }, ver: '1.0', } @@ -103,7 +105,7 @@ export function anonCredsRevocationStatusListFromIndySdk( const defaultState = isIssuanceByDefault ? 0 : 1 // Fill with default value - const revocationList = new Array(revocationRegistryDefinition.maxCredNum).fill(defaultState) + const revocationList = new Array(revocationRegistryDefinition.value.maxCredNum).fill(defaultState) // Set all `issuer` indexes to 0 (not revoked) for (const issued of delta.value.issued ?? []) { diff --git a/tests/InMemoryStorageService.ts b/tests/InMemoryStorageService.ts index 0b2a73ebb4..e1fc3f2f60 100644 --- a/tests/InMemoryStorageService.ts +++ b/tests/InMemoryStorageService.ts @@ -131,6 +131,7 @@ export class InMemoryStorageService implement } const records = Object.values(this.records) + .filter((record) => record.type === recordClass.type) .filter((record) => { const tags = record.tags as TagsBase diff --git a/yarn.lock b/yarn.lock index 4a8447c5fe..0f3abfb2f7 100644 --- a/yarn.lock +++ b/yarn.lock @@ -858,6 +858,24 @@ resolved "https://registry.yarnpkg.com/@hutson/parse-repository-url/-/parse-repository-url-3.0.2.tgz#98c23c950a3d9b6c8f0daed06da6c3af06981340" integrity sha512-H9XAx3hc0BQHY6l+IFSWHDySypcXsvsuLhgYLUGywmJ5pswRVQJUHpOsobnLYp2ZUaUlKiKDrgWWhosOwAEM8Q== +"@hyperledger/anoncreds-nodejs@^0.1.0-dev.5": + version "0.1.0-dev.5" + resolved "https://registry.yarnpkg.com/@hyperledger/anoncreds-nodejs/-/anoncreds-nodejs-0.1.0-dev.5.tgz#71b6dbcfab72f826bcead2b79dafe47fc8f1567c" + integrity sha512-BX/OxQjTMoCAJP4fgJEcGct1ZnNYgybO+VLD5LyzHW4nmTFOJo3TXy5IYHAJv61b/uNUQ/2GMYmPKLSLOVExNw== + dependencies: + "@hyperledger/anoncreds-shared" "0.1.0-dev.5" + "@mapbox/node-pre-gyp" "^1.0.10" + ffi-napi "4.0.3" + node-cache "5.1.2" + ref-array-di "1.2.2" + ref-napi "3.0.3" + ref-struct-di "1.1.1" + +"@hyperledger/anoncreds-shared@0.1.0-dev.5", "@hyperledger/anoncreds-shared@^0.1.0-dev.5": + version "0.1.0-dev.5" + resolved "https://registry.yarnpkg.com/@hyperledger/anoncreds-shared/-/anoncreds-shared-0.1.0-dev.5.tgz#653a8ec1ac83eae3af8aabb7fa5609bb0c3453b2" + integrity sha512-NPbjZd7WJN/eKtHtYcOy+E9Ebh0YkZ7bre59zWD3w66aiehZrSLbL5+pjY9shrSIN1h05t0XnvT1JZKTtXgqcQ== + "@hyperledger/aries-askar-nodejs@^0.1.0-dev.1": version "0.1.0-dev.1" resolved "https://registry.yarnpkg.com/@hyperledger/aries-askar-nodejs/-/aries-askar-nodejs-0.1.0-dev.1.tgz#b384d422de48f0ce5918e1612d2ca32ebd160520" @@ -5357,7 +5375,7 @@ fetch-blob@^2.1.1: resolved "https://registry.yarnpkg.com/fetch-blob/-/fetch-blob-2.1.2.tgz#a7805db1361bd44c1ef62bb57fb5fe8ea173ef3c" integrity sha512-YKqtUDwqLyfyMnmbw8XD6Q8j9i/HggKtPEI+pZ1+8bvheBu78biSmNaXWusx1TauGqtUUGx/cBb1mKdq2rLYow== -ffi-napi@^4.0.3: +ffi-napi@4.0.3, ffi-napi@^4.0.3: version "4.0.3" resolved "https://registry.yarnpkg.com/ffi-napi/-/ffi-napi-4.0.3.tgz#27a8d42a8ea938457154895c59761fbf1a10f441" integrity sha512-PMdLCIvDY9mS32RxZ0XGb95sonPRal8aqRhLbeEtWKZTe2A87qRFG9HjOhvG8EX2UmQw5XNRMIOT+1MYlWmdeg== @@ -8417,7 +8435,7 @@ node-addon-api@^3.0.0: resolved "https://registry.yarnpkg.com/node-addon-api/-/node-addon-api-3.2.1.tgz#81325e0a2117789c0128dab65e7e38f07ceba161" integrity sha512-mmcei9JghVNDYydghQmeDX8KoAm0FAiYyIcUt/N4nhyAipB17pllZQDOJD2fotxABnt4Mdz+dKTO7eftLg4d0A== -node-cache@^5.1.2: +node-cache@5.1.2, node-cache@^5.1.2: version "5.1.2" resolved "https://registry.yarnpkg.com/node-cache/-/node-cache-5.1.2.tgz#f264dc2ccad0a780e76253a694e9fd0ed19c398d" integrity sha512-t1QzWwnk4sjLWaQAS8CHgOJ+RAfmHpxFWmc36IWTiWHQfs0w5JDMBS1b1ZxQteo0vVVuWJvIUKHDkkeK7vIGCg== @@ -9723,7 +9741,7 @@ reduce-flatten@^2.0.0: resolved "https://registry.yarnpkg.com/reduce-flatten/-/reduce-flatten-2.0.0.tgz#734fd84e65f375d7ca4465c69798c25c9d10ae27" integrity sha512-EJ4UNY/U1t2P/2k6oqotuX2Cc3T6nxJwsM0N0asT7dhrtH1ltUxDn4NalSYmPE2rCkVpcf/X6R0wDwcFpzhd4w== -ref-array-di@^1.2.2: +ref-array-di@1.2.2, ref-array-di@^1.2.2: version "1.2.2" resolved "https://registry.yarnpkg.com/ref-array-di/-/ref-array-di-1.2.2.tgz#ceee9d667d9c424b5a91bb813457cc916fb1f64d" integrity sha512-jhCmhqWa7kvCVrWhR/d7RemkppqPUdxEil1CtTtm7FkZV8LcHHCK3Or9GinUiFP5WY3k0djUkMvhBhx49Jb2iA== @@ -9731,7 +9749,7 @@ ref-array-di@^1.2.2: array-index "^1.0.0" debug "^3.1.0" -"ref-napi@^2.0.1 || ^3.0.2", ref-napi@^3.0.3: +ref-napi@3.0.3, "ref-napi@^2.0.1 || ^3.0.2", ref-napi@^3.0.3: version "3.0.3" resolved "https://registry.yarnpkg.com/ref-napi/-/ref-napi-3.0.3.tgz#e259bfc2bbafb3e169e8cd9ba49037dd00396b22" integrity sha512-LiMq/XDGcgodTYOMppikEtJelWsKQERbLQsYm0IOOnzhwE9xYZC7x8txNnFC9wJNOkPferQI4vD4ZkC0mDyrOA== @@ -9741,7 +9759,7 @@ ref-array-di@^1.2.2: node-addon-api "^3.0.0" node-gyp-build "^4.2.1" -ref-struct-di@^1.1.0, ref-struct-di@^1.1.1: +ref-struct-di@1.1.1, ref-struct-di@^1.1.0, ref-struct-di@^1.1.1: version "1.1.1" resolved "https://registry.yarnpkg.com/ref-struct-di/-/ref-struct-di-1.1.1.tgz#5827b1d3b32372058f177547093db1fe1602dc10" integrity sha512-2Xyn/0Qgz89VT+++WP0sTosdm9oeowLP23wRJYhG4BFdMUrLj3jhwHZNEytYNYgtPKLNTP3KJX4HEgBvM1/Y2g==