From c72fd7416f2c1bc0497a84036e16adfa80585e49 Mon Sep 17 00:00:00 2001 From: Timo Glastra Date: Mon, 13 Feb 2023 18:40:44 +0100 Subject: [PATCH] feat(anoncreds): legacy indy proof format service (#1283) Signed-off-by: Timo Glastra --- .../src/services/AnonCredsRsHolderService.ts | 20 +- .../services/AnonCredsRsVerifierService.ts | 9 +- .../AnonCredsRsHolderService.test.ts | 10 +- .../__tests__/AnonCredsRsServices.test.ts | 17 +- packages/anoncreds-rs/tests/indy-flow.test.ts | 89 +- packages/anoncreds/package.json | 4 +- .../src/formats/AnonCredsCredentialFormat.ts | 30 +- .../src/formats/AnonCredsProofFormat.ts | 89 ++ .../src/formats/LegacyIndyCredentialFormat.ts | 33 +- .../LegacyIndyCredentialFormatService.ts | 50 +- .../src/formats/LegacyIndyProofFormat.ts | 38 + .../formats/LegacyIndyProofFormatService.ts | 802 ++++++++++++++++++ ...ts => legacy-indy-format-services.test.ts} | 80 +- packages/anoncreds/src/formats/index.ts | 7 + packages/anoncreds/src/index.ts | 5 +- .../src/models/AnonCredsCredentialProposal.ts | 111 +++ .../src/models/AnonCredsProofRequest.ts | 83 ++ .../src/models/AnonCredsRequestedAttribute.ts | 39 + .../src/models/AnonCredsRequestedPredicate.ts | 53 ++ .../src/models/AnonCredsRestriction.ts | 139 +++ .../src/models/AnonCredsRevocationInterval.ts | 18 + .../__tests__/AnonCredsRestriction.test.ts | 80 ++ packages/anoncreds/src/models/exchange.ts | 42 +- packages/anoncreds/src/models/internal.ts | 10 +- .../services/AnonCredsHolderServiceOptions.ts | 6 +- .../src/services/AnonCredsVerifierService.ts | 3 +- .../AnonCredsVerifierServiceOptions.ts | 2 +- .../utils/__tests__/areRequestsEqual.test.ts | 419 +++++++++ .../src/utils/__tests__/credential.test.ts | 8 +- .../__tests__/hasDuplicateGroupNames.test.ts | 70 ++ .../__tests__/revocationInterval.test.ts | 37 + .../sortRequestedCredentialsMatches.test.ts | 57 ++ .../anoncreds/src/utils/areRequestsEqual.ts | 156 ++++ .../src/utils/createRequestFromPreview.ts | 89 ++ packages/anoncreds/src/utils/credential.ts | 17 +- .../src/utils/hasDuplicateGroupNames.ts | 23 + packages/anoncreds/src/utils/index.ts | 8 + packages/anoncreds/src/utils/isMap.ts | 19 + .../anoncreds/src/utils/revocationInterval.ts | 17 + .../utils/sortRequestedCredentialsMatches.ts | 33 + packages/anoncreds/src/utils/tails.ts | 57 ++ packages/askar/src/utils/askarWalletConfig.ts | 4 +- packages/core/src/index.ts | 2 +- .../formats/CredentialFormatServiceOptions.ts | 6 +- .../models/CredentialPreviewAttribute.ts | 2 +- .../protocol/v1/V1CredentialProtocol.ts | 10 +- .../v1/messages/V1CredentialPreview.ts | 2 +- .../v2/messages/V2CredentialPreview.ts | 2 +- .../core/src/modules/proofs/models/index.ts | 1 + packages/core/src/storage/FileSystem.ts | 8 +- .../services/IndySdkHolderService.ts | 28 +- .../services/IndySdkRevocationService.ts | 16 +- .../services/IndySdkVerifierService.ts | 11 +- packages/node/package.json | 1 + packages/node/src/NodeFileSystem.ts | 26 +- packages/node/tests/NodeFileSystem.test.ts | 29 + packages/node/tests/__fixtures__/tailsFile | Bin 0 -> 65666 bytes .../react-native/src/ReactNativeFileSystem.ts | 22 +- 58 files changed, 2854 insertions(+), 195 deletions(-) create mode 100644 packages/anoncreds/src/formats/AnonCredsProofFormat.ts create mode 100644 packages/anoncreds/src/formats/LegacyIndyProofFormat.ts create mode 100644 packages/anoncreds/src/formats/LegacyIndyProofFormatService.ts rename packages/anoncreds/src/formats/__tests__/{LegacyIndyCredentialFormatService.test.ts => legacy-indy-format-services.test.ts} (72%) create mode 100644 packages/anoncreds/src/formats/index.ts create mode 100644 packages/anoncreds/src/models/AnonCredsCredentialProposal.ts create mode 100644 packages/anoncreds/src/models/AnonCredsProofRequest.ts create mode 100644 packages/anoncreds/src/models/AnonCredsRequestedAttribute.ts create mode 100644 packages/anoncreds/src/models/AnonCredsRequestedPredicate.ts create mode 100644 packages/anoncreds/src/models/AnonCredsRestriction.ts create mode 100644 packages/anoncreds/src/models/AnonCredsRevocationInterval.ts create mode 100644 packages/anoncreds/src/models/__tests__/AnonCredsRestriction.test.ts create mode 100644 packages/anoncreds/src/utils/__tests__/areRequestsEqual.test.ts create mode 100644 packages/anoncreds/src/utils/__tests__/hasDuplicateGroupNames.test.ts create mode 100644 packages/anoncreds/src/utils/__tests__/revocationInterval.test.ts create mode 100644 packages/anoncreds/src/utils/__tests__/sortRequestedCredentialsMatches.test.ts create mode 100644 packages/anoncreds/src/utils/areRequestsEqual.ts create mode 100644 packages/anoncreds/src/utils/createRequestFromPreview.ts create mode 100644 packages/anoncreds/src/utils/hasDuplicateGroupNames.ts create mode 100644 packages/anoncreds/src/utils/index.ts create mode 100644 packages/anoncreds/src/utils/isMap.ts create mode 100644 packages/anoncreds/src/utils/revocationInterval.ts create mode 100644 packages/anoncreds/src/utils/sortRequestedCredentialsMatches.ts create mode 100644 packages/anoncreds/src/utils/tails.ts create mode 100644 packages/node/tests/__fixtures__/tailsFile diff --git a/packages/anoncreds-rs/src/services/AnonCredsRsHolderService.ts b/packages/anoncreds-rs/src/services/AnonCredsRsHolderService.ts index e0c84fd7b1..5f6530c58a 100644 --- a/packages/anoncreds-rs/src/services/AnonCredsRsHolderService.ts +++ b/packages/anoncreds-rs/src/services/AnonCredsRsHolderService.ts @@ -12,9 +12,9 @@ import type { CreateLinkSecretOptions, CreateLinkSecretReturn, AnonCredsProofRequestRestriction, - AnonCredsRequestedAttribute, - AnonCredsRequestedPredicate, AnonCredsCredential, + AnonCredsRequestedAttributeMatch, + AnonCredsRequestedPredicateMatch, } from '@aries-framework/anoncreds' import type { AgentContext, Query, SimpleQuery } from '@aries-framework/core' import type { CredentialEntry, CredentialProve } from '@hyperledger/anoncreds-shared' @@ -63,7 +63,7 @@ export class AnonCredsRsHolderService implements AnonCredsHolderService { } public async createProof(agentContext: AgentContext, options: CreateProofOptions): Promise { - const { credentialDefinitions, proofRequest, requestedCredentials, schemas } = options + const { credentialDefinitions, proofRequest, selectedCredentials, schemas } = options try { const rsCredentialDefinitions: Record = {} @@ -82,7 +82,7 @@ export class AnonCredsRsHolderService implements AnonCredsHolderService { const retrievedCredentials = new Map() const credentialEntryFromAttribute = async ( - attribute: AnonCredsRequestedAttribute | AnonCredsRequestedPredicate + attribute: AnonCredsRequestedAttributeMatch | AnonCredsRequestedPredicateMatch ): Promise<{ linkSecretId: string; credentialEntry: CredentialEntry }> => { let credentialRecord = retrievedCredentials.get(attribute.credentialId) if (!credentialRecord) { @@ -136,15 +136,15 @@ export class AnonCredsRsHolderService implements AnonCredsHolderService { const credentials: { linkSecretId: string; credentialEntry: CredentialEntry }[] = [] let entryIndex = 0 - for (const referent in requestedCredentials.requestedAttributes) { - const attribute = requestedCredentials.requestedAttributes[referent] + for (const referent in selectedCredentials.attributes) { + const attribute = selectedCredentials.attributes[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] + for (const referent in selectedCredentials.predicates) { + const predicate = selectedCredentials.predicates[referent] credentials.push(await credentialEntryFromAttribute(predicate)) credentialsProve.push({ entryIndex, isPredicate: true, referent, reveal: true }) entryIndex = entryIndex + 1 @@ -170,7 +170,7 @@ export class AnonCredsRsHolderService implements AnonCredsHolderService { presentationRequest: PresentationRequest.load(JSON.stringify(proofRequest)), credentials: credentials.map((entry) => entry.credentialEntry), credentialsProve, - selfAttest: requestedCredentials.selfAttestedAttributes, + selfAttest: selectedCredentials.selfAttestedAttributes, masterSecret: MasterSecret.load(JSON.stringify({ value: { ms: linkSecretRecord.value } })), }) @@ -179,7 +179,7 @@ export class AnonCredsRsHolderService implements AnonCredsHolderService { agentContext.config.logger.error(`Error creating AnonCreds Proof`, { error, proofRequest, - requestedCredentials, + selectedCredentials, }) throw new AnonCredsRsError(`Error creating proof: ${error}`, { cause: error }) } diff --git a/packages/anoncreds-rs/src/services/AnonCredsRsVerifierService.ts b/packages/anoncreds-rs/src/services/AnonCredsRsVerifierService.ts index 96030d44ba..be4952d632 100644 --- a/packages/anoncreds-rs/src/services/AnonCredsRsVerifierService.ts +++ b/packages/anoncreds-rs/src/services/AnonCredsRsVerifierService.ts @@ -1,4 +1,5 @@ import type { AnonCredsVerifierService, VerifyProofOptions } from '@aries-framework/anoncreds' +import type { AgentContext } from '@aries-framework/core' import { injectable } from '@aries-framework/core' import { @@ -14,8 +15,8 @@ import { AnonCredsRsError } from '../errors/AnonCredsRsError' @injectable() export class AnonCredsRsVerifierService implements AnonCredsVerifierService { - public async verifyProof(options: VerifyProofOptions): Promise { - const { credentialDefinitions, proof, proofRequest, revocationStates, schemas } = options + public async verifyProof(agentContext: AgentContext, options: VerifyProofOptions): Promise { + const { credentialDefinitions, proof, proofRequest, revocationRegistries, schemas } = options try { const presentation = Presentation.load(JSON.stringify(proof)) @@ -33,8 +34,8 @@ export class AnonCredsRsVerifierService implements AnonCredsVerifierService { const revocationRegistryDefinitions: Record = {} const lists = [] - for (const revocationRegistryDefinitionId in revocationStates) { - const { definition, revocationStatusLists } = options.revocationStates[revocationRegistryDefinitionId] + for (const revocationRegistryDefinitionId in revocationRegistries) { + const { definition, revocationStatusLists } = options.revocationRegistries[revocationRegistryDefinitionId] revocationRegistryDefinitions[revocationRegistryDefinitionId] = RevocationRegistryDefinition.load( JSON.stringify(definition) diff --git a/packages/anoncreds-rs/src/services/__tests__/AnonCredsRsHolderService.test.ts b/packages/anoncreds-rs/src/services/__tests__/AnonCredsRsHolderService.test.ts index f0585f6ffb..bdfac8c48a 100644 --- a/packages/anoncreds-rs/src/services/__tests__/AnonCredsRsHolderService.test.ts +++ b/packages/anoncreds-rs/src/services/__tests__/AnonCredsRsHolderService.test.ts @@ -1,10 +1,10 @@ import type { AnonCredsCredentialDefinition, AnonCredsProofRequest, - AnonCredsRequestedCredentials, AnonCredsRevocationStatusList, AnonCredsCredential, AnonCredsSchema, + AnonCredsSelectedCredentials, } from '@aries-framework/anoncreds' import { @@ -191,15 +191,15 @@ describe('AnonCredsRsHolderService', () => { revocationRegistryDefinitionId: 'phonerevregid:uri', }) - const requestedCredentials: AnonCredsRequestedCredentials = { + const selectedCredentials: AnonCredsSelectedCredentials = { selfAttestedAttributes: { attr5_referent: 'football' }, - requestedAttributes: { + attributes: { 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: { + predicates: { predicate1_referent: { credentialId: 'personCredId', credentialInfo: personCredentialInfo }, }, } @@ -246,7 +246,7 @@ describe('AnonCredsRsHolderService', () => { 'phonecreddef:uri': phoneCredentialDefinition as AnonCredsCredentialDefinition, }, proofRequest, - requestedCredentials, + selectedCredentials, schemas: { 'phoneschema:uri': { attrNames: ['phoneNumber'], issuerId: 'issuer:uri', name: 'phoneschema', version: '1' }, 'personschema:uri': { diff --git a/packages/anoncreds-rs/src/services/__tests__/AnonCredsRsServices.test.ts b/packages/anoncreds-rs/src/services/__tests__/AnonCredsRsServices.test.ts index 3e23f27eb0..f881d22fa3 100644 --- a/packages/anoncreds-rs/src/services/__tests__/AnonCredsRsServices.test.ts +++ b/packages/anoncreds-rs/src/services/__tests__/AnonCredsRsServices.test.ts @@ -20,7 +20,7 @@ import { anoncreds } from '@hyperledger/anoncreds-nodejs' import { Subject } from 'rxjs' import { InMemoryStorageService } from '../../../../../tests/InMemoryStorageService' -import { encode } from '../../../../anoncreds/src/utils/credential' +import { encodeCredentialValue } from '../../../../anoncreds/src/utils/credential' import { InMemoryAnonCredsRegistry } from '../../../../anoncreds/tests/InMemoryAnonCredsRegistry' import { agentDependencies, getAgentConfig, getAgentContext } from '../../../../core/tests/helpers' import { AnonCredsRsHolderService } from '../AnonCredsRsHolderService' @@ -145,7 +145,10 @@ describe('AnonCredsRsServices', () => { const { credential } = await anonCredsIssuerService.createCredential(agentContext, { credentialOffer, credentialRequest: credentialRequestState.credentialRequest, - credentialValues: { name: { raw: 'John', encoded: encode('John') }, age: { raw: '25', encoded: encode('25') } }, + credentialValues: { + name: { raw: 'John', encoded: encodeCredentialValue('John') }, + age: { raw: '25', encoded: encodeCredentialValue('25') }, + }, }) const credentialId = 'holderCredentialId' @@ -197,12 +200,12 @@ describe('AnonCredsRsServices', () => { const proof = await anonCredsHolderService.createProof(agentContext, { credentialDefinitions: { [credentialDefinitionState.credentialDefinitionId]: credentialDefinition }, proofRequest, - requestedCredentials: { - requestedAttributes: { + selectedCredentials: { + attributes: { attr1_referent: { credentialId, credentialInfo, revealed: true }, attr2_referent: { credentialId, credentialInfo, revealed: true }, }, - requestedPredicates: { + predicates: { predicate1_referent: { credentialId, credentialInfo }, }, selfAttestedAttributes: {}, @@ -211,12 +214,12 @@ describe('AnonCredsRsServices', () => { revocationRegistries: {}, }) - const verifiedProof = await anonCredsVerifierService.verifyProof({ + const verifiedProof = await anonCredsVerifierService.verifyProof(agentContext, { credentialDefinitions: { [credentialDefinitionState.credentialDefinitionId]: credentialDefinition }, proof, proofRequest, schemas: { [schemaState.schemaId]: schema }, - revocationStates: {}, + revocationRegistries: {}, }) expect(verifiedProof).toBeTruthy() diff --git a/packages/anoncreds-rs/tests/indy-flow.test.ts b/packages/anoncreds-rs/tests/indy-flow.test.ts index fc2ce9ec87..b201b8c31e 100644 --- a/packages/anoncreds-rs/tests/indy-flow.test.ts +++ b/packages/anoncreds-rs/tests/indy-flow.test.ts @@ -1,10 +1,11 @@ +import type { Wallet } from '@aries-framework/core' + import { AnonCredsModuleConfig, LegacyIndyCredentialFormatService, AnonCredsHolderServiceSymbol, AnonCredsIssuerServiceSymbol, AnonCredsVerifierServiceSymbol, - AnonCredsRegistryService, AnonCredsSchemaRecord, AnonCredsSchemaRepository, AnonCredsCredentialDefinitionRepository, @@ -15,16 +16,20 @@ import { AnonCredsKeyCorrectnessProofRecord, AnonCredsLinkSecretRepository, AnonCredsLinkSecretRecord, + LegacyIndyProofFormatService, } from '@aries-framework/anoncreds' import { CredentialState, CredentialExchangeRecord, CredentialPreviewAttribute, InjectionSymbols, + ProofState, + ProofExchangeRecord, } from '@aries-framework/core' import { Subject } from 'rxjs' import { InMemoryStorageService } from '../../../tests/InMemoryStorageService' +import { AnonCredsRegistryService } from '../../anoncreds/src/services/registry/AnonCredsRegistryService' import { InMemoryAnonCredsRegistry } from '../../anoncreds/tests/InMemoryAnonCredsRegistry' import { agentDependencies, getAgentConfig, getAgentContext } from '../../core/tests/helpers' import { AnonCredsRsHolderService } from '../src/services/AnonCredsRsHolderService' @@ -36,11 +41,13 @@ const anonCredsModuleConfig = new AnonCredsModuleConfig({ registries: [registry], }) -const agentConfig = getAgentConfig('LegacyIndyCredentialFormatService') +const agentConfig = getAgentConfig('LegacyIndyCredentialFormatService using anoncreds-rs') const anonCredsVerifierService = new AnonCredsRsVerifierService() const anonCredsHolderService = new AnonCredsRsHolderService() const anonCredsIssuerService = new AnonCredsRsIssuerService() +const wallet = { generateNonce: () => Promise.resolve('947121108704767252195123') } as Wallet + const inMemoryStorageService = new InMemoryStorageService() const agentContext = getAgentContext({ registerInstances: [ @@ -54,15 +61,17 @@ const agentContext = getAgentContext({ [AnonCredsModuleConfig, anonCredsModuleConfig], ], agentConfig, + wallet, }) const legacyIndyCredentialFormatService = new LegacyIndyCredentialFormatService() +const legacyIndyProofFormatService = new LegacyIndyProofFormatService() -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' +// 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' +describe('Legacy indy format services using anoncreds-rs', () => { + 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, @@ -70,7 +79,7 @@ describe('LegacyIndyCredentialFormatService using anoncreds-rs', () => { version: '1.0.0', }) - const { schemaState, schemaMetadata } = await registry.registerSchema(agentContext, { + const { schemaState } = await registry.registerSchema(agentContext, { schema, options: {}, }) @@ -273,5 +282,71 @@ describe('LegacyIndyCredentialFormatService using anoncreds-rs', () => { credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, }, }) + + 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', + }) + + const { attachment: proofProposalAttachment } = await legacyIndyProofFormatService.createProposal(agentContext, { + proofFormats: { + indy: { + 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, + }) + + await legacyIndyProofFormatService.processProposal(agentContext, { + attachment: proofProposalAttachment, + proofRecord: verifierProofRecord, + }) + + const { attachment: proofRequestAttachment } = await legacyIndyProofFormatService.acceptProposal(agentContext, { + proofRecord: verifierProofRecord, + proposalAttachment: proofProposalAttachment, + }) + + await legacyIndyProofFormatService.processRequest(agentContext, { + attachment: proofRequestAttachment, + proofRecord: holderProofRecord, + }) + + const { attachment: proofAttachment } = await legacyIndyProofFormatService.acceptRequest(agentContext, { + proofRecord: holderProofRecord, + requestAttachment: proofRequestAttachment, + proposalAttachment: proofProposalAttachment, + }) + + const isValid = await legacyIndyProofFormatService.processPresentation(agentContext, { + attachment: proofAttachment, + proofRecord: verifierProofRecord, + requestAttachment: proofRequestAttachment, + }) + + expect(isValid).toBe(true) }) }) diff --git a/packages/anoncreds/package.json b/packages/anoncreds/package.json index 75c6d2d6a4..27ddffa7d6 100644 --- a/packages/anoncreds/package.json +++ b/packages/anoncreds/package.json @@ -26,7 +26,9 @@ "dependencies": { "@aries-framework/core": "0.3.3", "@aries-framework/node": "0.3.3", - "bn.js": "^5.2.1" + "bn.js": "^5.2.1", + "class-transformer": "0.5.1", + "class-validator": "0.13.1" }, "devDependencies": { "indy-sdk": "^1.16.0-dev-1636", diff --git a/packages/anoncreds/src/formats/AnonCredsCredentialFormat.ts b/packages/anoncreds/src/formats/AnonCredsCredentialFormat.ts index e08109f56f..dba5361a41 100644 --- a/packages/anoncreds/src/formats/AnonCredsCredentialFormat.ts +++ b/packages/anoncreds/src/formats/AnonCredsCredentialFormat.ts @@ -1,6 +1,21 @@ import type { AnonCredsCredential, AnonCredsCredentialOffer, AnonCredsCredentialRequest } from '../models' import type { CredentialPreviewAttributeOptions, CredentialFormat, LinkedAttachment } from '@aries-framework/core' +export interface AnonCredsCredentialProposalFormat { + schema_issuer_id?: string + schema_name?: string + schema_version?: string + schema_id?: string + + cred_def_id?: string + issuer_id?: string + + // TODO: we don't necessarily need to include these in the AnonCreds Format RFC + // as it's a new one and we can just forbid the use of legacy properties + schema_issuer_did?: string + issuer_did?: string +} + /** * This defines the module payload for calling CredentialsApi.createProposal * or CredentialsApi.negotiateOffer @@ -70,20 +85,7 @@ export interface AnonCredsCredentialFormat extends CredentialFormat { // Format data is based on RFC 0592 // https://github.com/hyperledger/aries-rfcs/tree/main/features/0592-indy-attachments formatData: { - proposal: { - schema_issuer_id?: string - schema_name?: string - schema_version?: string - schema_id?: string - - cred_def_id?: string - issuer_id?: string - - // TODO: we don't necessarily need to include these in the AnonCreds Format RFC - // as it's a new one and we can just forbid the use of legacy properties - schema_issuer_did?: string - issuer_did?: string - } + proposal: AnonCredsCredentialProposalFormat offer: AnonCredsCredentialOffer request: AnonCredsCredentialRequest credential: AnonCredsCredential diff --git a/packages/anoncreds/src/formats/AnonCredsProofFormat.ts b/packages/anoncreds/src/formats/AnonCredsProofFormat.ts new file mode 100644 index 0000000000..2bfeb689dc --- /dev/null +++ b/packages/anoncreds/src/formats/AnonCredsProofFormat.ts @@ -0,0 +1,89 @@ +import type { + AnonCredsNonRevokedInterval, + AnonCredsPredicateType, + AnonCredsProof, + AnonCredsProofRequest, + AnonCredsRequestedAttribute, + AnonCredsRequestedAttributeMatch, + AnonCredsRequestedPredicate, + AnonCredsRequestedPredicateMatch, + AnonCredsSelectedCredentials, +} from '../models' +import type { ProofFormat } from '@aries-framework/core' + +export interface AnonCredsPresentationPreviewAttribute { + name: string + credentialDefinitionId?: string + mimeType?: string + value?: string + referent?: string +} + +export interface AnonCredsPresentationPreviewPredicate { + name: string + credentialDefinitionId: string + predicate: AnonCredsPredicateType + threshold: number +} + +/** + * Interface for creating an anoncreds proof proposal. + */ +export interface AnonCredsProposeProofFormat { + name?: string + version?: string + attributes?: AnonCredsPresentationPreviewAttribute[] + predicates?: AnonCredsPresentationPreviewPredicate[] +} + +/** + * Interface for creating an anoncreds proof request. + */ +export interface AnonCredsRequestProofFormat { + name: string + version: string + nonRevoked?: AnonCredsNonRevokedInterval + requestedAttributes?: Record + requestedPredicates?: Record +} + +/** + * Interface for getting credentials for an indy proof request. + */ +export interface AnonCredsCredentialsForProofRequest { + attributes: Record + predicates: Record +} + +export interface AnonCredsGetCredentialsForProofRequestOptions { + filterByNonRevocationRequirements?: boolean +} + +export interface AnonCredsProofFormat extends ProofFormat { + formatKey: 'anoncreds' + + proofFormats: { + createProposal: AnonCredsProposeProofFormat + acceptProposal: { + name?: string + version?: string + } + createRequest: AnonCredsRequestProofFormat + acceptRequest: AnonCredsSelectedCredentials + + getCredentialsForRequest: { + input: AnonCredsGetCredentialsForProofRequestOptions + output: AnonCredsCredentialsForProofRequest + } + selectCredentialsForRequest: { + input: AnonCredsGetCredentialsForProofRequestOptions + output: AnonCredsSelectedCredentials + } + } + + formatData: { + proposal: AnonCredsProofRequest + request: AnonCredsProofRequest + presentation: AnonCredsProof + } +} diff --git a/packages/anoncreds/src/formats/LegacyIndyCredentialFormat.ts b/packages/anoncreds/src/formats/LegacyIndyCredentialFormat.ts index ce9be1e3eb..78342fe833 100644 --- a/packages/anoncreds/src/formats/LegacyIndyCredentialFormat.ts +++ b/packages/anoncreds/src/formats/LegacyIndyCredentialFormat.ts @@ -2,10 +2,18 @@ import type { AnonCredsAcceptOfferFormat, AnonCredsAcceptProposalFormat, AnonCredsAcceptRequestFormat, + AnonCredsCredentialProposalFormat, AnonCredsOfferCredentialFormat, + AnonCredsProposeCredentialFormat, } from './AnonCredsCredentialFormat' import type { AnonCredsCredential, AnonCredsCredentialOffer, AnonCredsCredentialRequest } from '../models' -import type { CredentialPreviewAttributeOptions, CredentialFormat, LinkedAttachment } from '@aries-framework/core' +import type { CredentialFormat } from '@aries-framework/core' + +// Legacy indy credential proposal doesn't support _id properties +export type LegacyIndyCredentialProposalFormat = Omit< + AnonCredsCredentialProposalFormat, + 'schema_issuer_id' | 'issuer_id' +> /** * This defines the module payload for calling CredentialsApi.createProposal @@ -13,18 +21,7 @@ import type { CredentialPreviewAttributeOptions, CredentialFormat, LinkedAttachm * * NOTE: This doesn't include the `issuerId` and `schemaIssuerId` properties that are present in the newer format. */ -export interface LegacyIndyProposeCredentialFormat { - schemaIssuerDid?: string - schemaId?: string - schemaName?: string - schemaVersion?: string - - credentialDefinitionId?: string - issuerDid?: string - - attributes?: CredentialPreviewAttributeOptions[] - linkedAttachments?: LinkedAttachment[] -} +type LegacyIndyProposeCredentialFormat = Omit export interface LegacyIndyCredentialRequest extends AnonCredsCredentialRequest { // prover_did is optional in AnonCreds credential request, but required in legacy format @@ -51,15 +48,7 @@ export interface LegacyIndyCredentialFormat extends CredentialFormat { // Format data is based on RFC 0592 // https://github.com/hyperledger/aries-rfcs/tree/main/features/0592-indy-attachments formatData: { - proposal: { - schema_name?: string - schema_issuer_did?: string - schema_version?: string - schema_id?: string - - cred_def_id?: string - issuer_did?: string - } + proposal: LegacyIndyCredentialProposalFormat offer: AnonCredsCredentialOffer request: LegacyIndyCredentialRequest credential: AnonCredsCredential diff --git a/packages/anoncreds/src/formats/LegacyIndyCredentialFormatService.ts b/packages/anoncreds/src/formats/LegacyIndyCredentialFormatService.ts index 7b2dbf3b72..93e2151870 100644 --- a/packages/anoncreds/src/formats/LegacyIndyCredentialFormatService.ts +++ b/packages/anoncreds/src/formats/LegacyIndyCredentialFormatService.ts @@ -1,4 +1,4 @@ -import type { LegacyIndyCredentialFormat } from './LegacyIndyCredentialFormat' +import type { LegacyIndyCredentialFormat, LegacyIndyCredentialProposalFormat } from './LegacyIndyCredentialFormat' import type { AnonCredsCredential, AnonCredsCredentialOffer, @@ -30,21 +30,19 @@ import type { } from '@aries-framework/core' import { + MessageValidator, CredentialFormatSpec, AriesFrameworkError, - IndyCredPropose, - JsonTransformer, Attachment, - CredentialPreviewAttribute, - AttachmentData, JsonEncoder, utils, - MessageValidator, CredentialProblemReportError, CredentialProblemReportReason, + JsonTransformer, } from '@aries-framework/core' import { AnonCredsError } from '../error' +import { AnonCredsCredentialProposal } from '../models/AnonCredsCredentialProposal' import { AnonCredsIssuerServiceSymbol, AnonCredsHolderServiceSymbol } from '../services' import { AnonCredsRegistryService } from '../services/registry/AnonCredsRegistryService' import { @@ -96,8 +94,7 @@ export class LegacyIndyCredentialFormatService implements CredentialFormatServic // The easiest way is to destructure and use the spread operator. But that leaves the other properties unused // eslint-disable-next-line @typescript-eslint/no-unused-vars const { attributes, linkedAttachments, ...indyCredentialProposal } = indyFormat - - const proposal = new IndyCredPropose(indyCredentialProposal) + const proposal = new AnonCredsCredentialProposal(indyCredentialProposal) try { MessageValidator.validateSync(proposal) @@ -105,8 +102,7 @@ export class LegacyIndyCredentialFormatService implements CredentialFormatServic throw new AriesFrameworkError(`Invalid proposal supplied: ${indyCredentialProposal} in Indy Format Service`) } - const proposalJson = JsonTransformer.toJSON(proposal) - const attachment = this.getFormatData(proposalJson, format.attachmentId) + const attachment = this.getFormatData(JsonTransformer.toJSON(proposal), format.attachmentId) const { previewAttributes } = this.getCredentialLinkedAttachments( indyFormat.attributes, @@ -128,8 +124,7 @@ export class LegacyIndyCredentialFormatService implements CredentialFormatServic ): Promise { const proposalJson = attachment.getDataAsJson() - // fromJSON also validates - JsonTransformer.fromJSON(proposalJson, IndyCredPropose) + JsonTransformer.fromJSON(proposalJson, AnonCredsCredentialProposal) } public async acceptProposal( @@ -143,9 +138,8 @@ export class LegacyIndyCredentialFormatService implements CredentialFormatServic ): Promise { const indyFormat = credentialFormats?.indy - const credentialProposal = JsonTransformer.fromJSON(proposalAttachment.getDataAsJson(), IndyCredPropose) - - const credentialDefinitionId = indyFormat?.credentialDefinitionId ?? credentialProposal.credentialDefinitionId + const proposalJson = proposalAttachment.getDataAsJson() + const credentialDefinitionId = indyFormat?.credentialDefinitionId ?? proposalJson.cred_def_id const attributes = indyFormat?.attributes ?? credentialRecord.credentialAttributes @@ -463,30 +457,26 @@ export class LegacyIndyCredentialFormatService implements CredentialFormatServic agentContext: AgentContext, { offerAttachment, proposalAttachment }: CredentialFormatAutoRespondProposalOptions ) { - const credentialProposalJson = proposalAttachment.getDataAsJson() - const credentialProposal = JsonTransformer.fromJSON(credentialProposalJson, IndyCredPropose) - - const credentialOfferJson = offerAttachment.getDataAsJson() + const proposalJson = proposalAttachment.getDataAsJson() + const offerJson = offerAttachment.getDataAsJson() // We want to make sure the credential definition matches. // TODO: If no credential definition is present on the proposal, we could check whether the other fields // of the proposal match with the credential definition id. - return credentialProposal.credentialDefinitionId === credentialOfferJson.cred_def_id + return proposalJson.cred_def_id === offerJson.cred_def_id } public async shouldAutoRespondToOffer( agentContext: AgentContext, { offerAttachment, proposalAttachment }: CredentialFormatAutoRespondOfferOptions ) { - const credentialProposalJson = proposalAttachment.getDataAsJson() - const credentialProposal = JsonTransformer.fromJSON(credentialProposalJson, IndyCredPropose) - - const credentialOfferJson = offerAttachment.getDataAsJson() + const proposalJson = proposalAttachment.getDataAsJson() + const offerJson = offerAttachment.getDataAsJson() // We want to make sure the credential definition matches. // TODO: If no credential definition is present on the proposal, we could check whether the other fields // of the proposal match with the credential definition id. - return credentialProposal.credentialDefinitionId === credentialOfferJson.cred_def_id + return proposalJson.cred_def_id === offerJson.cred_def_id } public async shouldAutoRespondToRequest( @@ -566,7 +556,7 @@ export class LegacyIndyCredentialFormatService implements CredentialFormatServic private async assertPreviewAttributesMatchSchemaAttributes( agentContext: AgentContext, offer: AnonCredsCredentialOffer, - attributes: CredentialPreviewAttribute[] + attributes: CredentialPreviewAttributeOptions[] ): Promise { const registryService = agentContext.dependencyManager.resolve(AnonCredsRegistryService) const registry = registryService.getRegistryForIdentifier(agentContext, offer.schema_id) @@ -594,13 +584,13 @@ export class LegacyIndyCredentialFormatService implements CredentialFormatServic linkedAttachments?: LinkedAttachment[] ): { attachments?: Attachment[] - previewAttributes?: CredentialPreviewAttribute[] + previewAttributes?: CredentialPreviewAttributeOptions[] } { if (!linkedAttachments && !attributes) { return {} } - let previewAttributes = attributes?.map((attribute) => new CredentialPreviewAttribute(attribute)) ?? [] + let previewAttributes = attributes ?? [] let attachments: Attachment[] | undefined if (linkedAttachments) { @@ -624,9 +614,9 @@ export class LegacyIndyCredentialFormatService implements CredentialFormatServic const attachment = new Attachment({ id, mimeType: 'application/json', - data: new AttachmentData({ + data: { base64: JsonEncoder.toBase64(data), - }), + }, }) return attachment diff --git a/packages/anoncreds/src/formats/LegacyIndyProofFormat.ts b/packages/anoncreds/src/formats/LegacyIndyProofFormat.ts new file mode 100644 index 0000000000..c2dfc2cf0d --- /dev/null +++ b/packages/anoncreds/src/formats/LegacyIndyProofFormat.ts @@ -0,0 +1,38 @@ +import type { + AnonCredsProposeProofFormat, + AnonCredsRequestProofFormat, + AnonCredsGetCredentialsForProofRequestOptions, + AnonCredsCredentialsForProofRequest, +} from './AnonCredsProofFormat' +import type { AnonCredsProof, AnonCredsProofRequest, AnonCredsSelectedCredentials } from '../models' +import type { ProofFormat } from '@aries-framework/core' + +export interface LegacyIndyProofFormat extends ProofFormat { + formatKey: 'indy' + + proofFormats: { + createProposal: AnonCredsProposeProofFormat + acceptProposal: { + name?: string + version?: string + } + createRequest: AnonCredsRequestProofFormat + acceptRequest: AnonCredsSelectedCredentials + + getCredentialsForRequest: { + input: AnonCredsGetCredentialsForProofRequestOptions + output: AnonCredsCredentialsForProofRequest + } + selectCredentialsForRequest: { + input: AnonCredsGetCredentialsForProofRequestOptions + output: AnonCredsSelectedCredentials + } + } + + formatData: { + // TODO: Custom restrictions to remove `_id` from restrictions? + proposal: AnonCredsProofRequest + request: AnonCredsProofRequest + presentation: AnonCredsProof + } +} diff --git a/packages/anoncreds/src/formats/LegacyIndyProofFormatService.ts b/packages/anoncreds/src/formats/LegacyIndyProofFormatService.ts new file mode 100644 index 0000000000..7cf5b18786 --- /dev/null +++ b/packages/anoncreds/src/formats/LegacyIndyProofFormatService.ts @@ -0,0 +1,802 @@ +import type { + AnonCredsCredentialsForProofRequest, + AnonCredsGetCredentialsForProofRequestOptions, +} from './AnonCredsProofFormat' +import type { LegacyIndyProofFormat } from './LegacyIndyProofFormat' +import type { + AnonCredsCredentialDefinition, + AnonCredsCredentialInfo, + AnonCredsProof, + AnonCredsRequestedAttribute, + AnonCredsRequestedAttributeMatch, + AnonCredsRequestedPredicate, + AnonCredsRequestedPredicateMatch, + AnonCredsSchema, + AnonCredsSelectedCredentials, + AnonCredsProofRequest, +} from '../models' +import type { + AnonCredsHolderService, + AnonCredsVerifierService, + CreateProofOptions, + GetCredentialsForProofRequestReturn, + VerifyProofOptions, +} from '../services' +import type { + ProofFormatService, + AgentContext, + ProofFormatCreateReturn, + FormatCreateRequestOptions, + ProofFormatCreateProposalOptions, + ProofFormatProcessOptions, + ProofFormatAcceptProposalOptions, + ProofFormatAcceptRequestOptions, + ProofFormatProcessPresentationOptions, + ProofFormatGetCredentialsForRequestOptions, + ProofFormatGetCredentialsForRequestReturn, + ProofFormatSelectCredentialsForRequestOptions, + ProofFormatSelectCredentialsForRequestReturn, + ProofFormatAutoRespondProposalOptions, + ProofFormatAutoRespondRequestOptions, + IndyGetCredentialsForProofRequestOptions, +} from '@aries-framework/core' + +import { + AriesFrameworkError, + Attachment, + AttachmentData, + JsonEncoder, + ProofFormatSpec, + JsonTransformer, +} from '@aries-framework/core' + +import { AnonCredsProofRequest as AnonCredsProofRequestClass } from '../models/AnonCredsProofRequest' +import { AnonCredsVerifierServiceSymbol, AnonCredsHolderServiceSymbol } from '../services' +import { AnonCredsRegistryService } from '../services/registry/AnonCredsRegistryService' +import { + sortRequestedCredentialsMatches, + createRequestFromPreview, + hasDuplicateGroupsNamesInProofRequest, + areAnonCredsProofRequestsEqual, + assertRevocationInterval, + downloadTailsFile, + checkValidCredentialValueEncoding, + encodeCredentialValue, +} from '../utils' + +const V2_INDY_PRESENTATION_PROPOSAL = 'hlindy/proof-req@v2.0' +const V2_INDY_PRESENTATION_REQUEST = 'hlindy/proof-req@v2.0' +const V2_INDY_PRESENTATION = 'hlindy/proof@v2.0' + +export class LegacyIndyProofFormatService implements ProofFormatService { + public readonly formatKey = 'indy' as const + + public async createProposal( + agentContext: AgentContext, + { attachmentId, proofFormats }: ProofFormatCreateProposalOptions + ): Promise { + const format = new ProofFormatSpec({ + format: V2_INDY_PRESENTATION_PROPOSAL, + attachmentId, + }) + + const indyFormat = proofFormats.indy + if (!indyFormat) { + throw Error('Missing indy format to create proposal attachment format') + } + + const proofRequest = createRequestFromPreview({ + attributes: indyFormat.attributes ?? [], + predicates: indyFormat.predicates ?? [], + name: indyFormat.name ?? 'Proof request', + version: indyFormat.version ?? '1.0', + nonce: await agentContext.wallet.generateNonce(), + }) + const attachment = this.getFormatData(proofRequest, format.attachmentId) + + return { attachment, format } + } + + public async processProposal(agentContext: AgentContext, { attachment }: ProofFormatProcessOptions): Promise { + const proposalJson = attachment.getDataAsJson() + + // fromJson also validates + JsonTransformer.fromJSON(proposalJson, AnonCredsProofRequestClass) + + // Assert attribute and predicate (group) names do not match + if (hasDuplicateGroupsNamesInProofRequest(proposalJson)) { + throw new AriesFrameworkError('Attribute and predicate (group) names must be unique in proof request') + } + } + + public async acceptProposal( + agentContext: AgentContext, + { proposalAttachment, attachmentId }: ProofFormatAcceptProposalOptions + ): Promise { + const format = new ProofFormatSpec({ + format: V2_INDY_PRESENTATION_REQUEST, + attachmentId, + }) + + const proposalJson = proposalAttachment.getDataAsJson() + + const request = { + ...proposalJson, + // We never want to reuse the nonce from the proposal, as this will allow replay attacks + nonce: await agentContext.wallet.generateNonce(), + } + + const attachment = this.getFormatData(request, format.attachmentId) + + return { attachment, format } + } + + public async createRequest( + agentContext: AgentContext, + { attachmentId, proofFormats }: FormatCreateRequestOptions + ): Promise { + const format = new ProofFormatSpec({ + format: V2_INDY_PRESENTATION_REQUEST, + attachmentId, + }) + + const indyFormat = proofFormats.indy + if (!indyFormat) { + throw Error('Missing indy format in create request attachment format') + } + + const request = { + name: indyFormat.name, + version: indyFormat.version, + nonce: await agentContext.wallet.generateNonce(), + requested_attributes: indyFormat.requestedAttributes ?? {}, + requested_predicates: indyFormat.requestedPredicates ?? {}, + non_revoked: indyFormat.nonRevoked, + } satisfies AnonCredsProofRequest + + // Validate to make sure user provided correct input + if (hasDuplicateGroupsNamesInProofRequest(request)) { + throw new AriesFrameworkError('Attribute and predicate (group) names must be unique in proof request') + } + + const attachment = this.getFormatData(request, format.attachmentId) + + return { attachment, format } + } + + public async processRequest(agentContext: AgentContext, { attachment }: ProofFormatProcessOptions): Promise { + const requestJson = attachment.getDataAsJson() + + // fromJson also validates + JsonTransformer.fromJSON(requestJson, AnonCredsProofRequestClass) + + // Assert attribute and predicate (group) names do not match + if (hasDuplicateGroupsNamesInProofRequest(requestJson)) { + throw new AriesFrameworkError('Attribute and predicate (group) names must be unique in proof request') + } + } + + public async acceptRequest( + agentContext: AgentContext, + { proofFormats, requestAttachment, attachmentId }: ProofFormatAcceptRequestOptions + ): Promise { + const format = new ProofFormatSpec({ + format: V2_INDY_PRESENTATION, + attachmentId, + }) + const requestJson = requestAttachment.getDataAsJson() + + const indyFormat = proofFormats?.indy + + const selectedCredentials = + indyFormat ?? + (await this._selectCredentialsForRequest(agentContext, requestJson, { + filterByNonRevocationRequirements: true, + })) + + const proof = await this.createProof(agentContext, requestJson, selectedCredentials) + const attachment = this.getFormatData(proof, format.attachmentId) + + return { + attachment, + format, + } + } + + public async processPresentation( + agentContext: AgentContext, + { requestAttachment, attachment }: ProofFormatProcessPresentationOptions + ): Promise { + const verifierService = + agentContext.dependencyManager.resolve(AnonCredsVerifierServiceSymbol) + + const proofRequestJson = requestAttachment.getDataAsJson() + + // NOTE: we don't do validation here, as this is handled by the AnonCreds implementation, however + // this can lead to confusing error messages. We should consider doing validation here as well. + // Defining a class-transformer/class-validator class seems a bit overkill, and the usage of interfaces + // for the anoncreds package keeps things simple. Maybe we can try to use something like zod to validate + const proofJson = attachment.getDataAsJson() + + for (const [referent, attribute] of Object.entries(proofJson.requested_proof.revealed_attrs)) { + if (!checkValidCredentialValueEncoding(attribute.raw, attribute.encoded)) { + throw new AriesFrameworkError( + `The encoded value for '${referent}' is invalid. ` + + `Expected '${encodeCredentialValue(attribute.raw)}'. ` + + `Actual '${attribute.encoded}'` + ) + } + } + + for (const [, attributeGroup] of Object.entries(proofJson.requested_proof.revealed_attr_groups ?? {})) { + for (const [attributeName, attribute] of Object.entries(attributeGroup.values)) { + if (!checkValidCredentialValueEncoding(attribute.raw, attribute.encoded)) { + throw new AriesFrameworkError( + `The encoded value for '${attributeName}' is invalid. ` + + `Expected '${encodeCredentialValue(attribute.raw)}'. ` + + `Actual '${attribute.encoded}'` + ) + } + } + } + + // TODO: pre verify proof json + // I'm not 100% sure how much indy does. Also if it checks whether the proof requests matches the proof + // @see https://github.com/hyperledger/aries-cloudagent-python/blob/master/aries_cloudagent/indy/sdk/verifier.py#L79-L164 + + const schemas = await this.getSchemas(agentContext, new Set(proofJson.identifiers.map((i) => i.schema_id))) + const credentialDefinitions = await this.getCredentialDefinitions( + agentContext, + new Set(proofJson.identifiers.map((i) => i.cred_def_id)) + ) + + const revocationRegistries = await this.getRevocationRegistriesForProof(agentContext, proofJson) + + return await verifierService.verifyProof(agentContext, { + proofRequest: proofRequestJson, + proof: proofJson, + schemas, + credentialDefinitions, + revocationRegistries, + }) + } + + public async getCredentialsForRequest( + agentContext: AgentContext, + { requestAttachment, proofFormats }: ProofFormatGetCredentialsForRequestOptions + ): Promise> { + const proofRequestJson = requestAttachment.getDataAsJson() + + // Set default values + const { filterByNonRevocationRequirements = true } = proofFormats?.indy ?? {} + + const credentialsForRequest = await this._getCredentialsForRequest(agentContext, proofRequestJson, { + filterByNonRevocationRequirements, + }) + + return credentialsForRequest + } + + public async selectCredentialsForRequest( + agentContext: AgentContext, + { requestAttachment, proofFormats }: ProofFormatSelectCredentialsForRequestOptions + ): Promise> { + const proofRequestJson = requestAttachment.getDataAsJson() + + // Set default values + const { filterByNonRevocationRequirements = true } = proofFormats?.indy ?? {} + + const selectedCredentials = this._selectCredentialsForRequest(agentContext, proofRequestJson, { + filterByNonRevocationRequirements, + }) + + return selectedCredentials + } + + public async shouldAutoRespondToProposal( + agentContext: AgentContext, + { proposalAttachment, requestAttachment }: ProofFormatAutoRespondProposalOptions + ): Promise { + const proposalJson = proposalAttachment.getDataAsJson() + const requestJson = requestAttachment.getDataAsJson() + + const areRequestsEqual = areAnonCredsProofRequestsEqual(proposalJson, requestJson) + agentContext.config.logger.debug(`AnonCreds request and proposal are are equal: ${areRequestsEqual}`, { + proposalJson, + requestJson, + }) + + return areRequestsEqual + } + + public async shouldAutoRespondToRequest( + agentContext: AgentContext, + { proposalAttachment, requestAttachment }: ProofFormatAutoRespondRequestOptions + ): Promise { + const proposalJson = proposalAttachment.getDataAsJson() + const requestJson = requestAttachment.getDataAsJson() + + return areAnonCredsProofRequestsEqual(proposalJson, requestJson) + } + + public async shouldAutoRespondToPresentation(): Promise { + // The presentation is already verified in processPresentation, so we can just return true here. + // It's only an ack, so it's just that we received the presentation. + return true + } + + public supportsFormat(formatIdentifier: string): boolean { + const supportedFormats = [V2_INDY_PRESENTATION_PROPOSAL, V2_INDY_PRESENTATION_REQUEST, V2_INDY_PRESENTATION] + return supportedFormats.includes(formatIdentifier) + } + + private async _getCredentialsForRequest( + agentContext: AgentContext, + proofRequest: AnonCredsProofRequest, + options: IndyGetCredentialsForProofRequestOptions + ): Promise { + const credentialsForProofRequest: AnonCredsCredentialsForProofRequest = { + attributes: {}, + predicates: {}, + } + + for (const [referent, requestedAttribute] of Object.entries(proofRequest.requested_attributes)) { + const credentials = await this.getCredentialsForProofRequestReferent(agentContext, proofRequest, referent) + + credentialsForProofRequest.attributes[referent] = sortRequestedCredentialsMatches( + await Promise.all( + credentials.map(async (credential) => { + const { isRevoked, timestamp } = await this.getRevocationStatus( + agentContext, + proofRequest, + requestedAttribute, + credential.credentialInfo + ) + + return { + credentialId: credential.credentialInfo.credentialId, + revealed: true, + credentialInfo: credential.credentialInfo, + timestamp, + revoked: isRevoked, + } satisfies AnonCredsRequestedAttributeMatch + }) + ) + ) + + // We only attach revoked state if non-revocation is requested. So if revoked is true it means + // the credential is not applicable to the proof request + if (options.filterByNonRevocationRequirements) { + credentialsForProofRequest.attributes[referent] = credentialsForProofRequest.attributes[referent].filter( + (r) => !r.revoked + ) + } + } + + for (const [referent, requestedPredicate] of Object.entries(proofRequest.requested_predicates)) { + const credentials = await this.getCredentialsForProofRequestReferent(agentContext, proofRequest, referent) + + credentialsForProofRequest.predicates[referent] = sortRequestedCredentialsMatches( + await Promise.all( + credentials.map(async (credential) => { + const { isRevoked, timestamp } = await this.getRevocationStatus( + agentContext, + proofRequest, + requestedPredicate, + credential.credentialInfo + ) + + return { + credentialId: credential.credentialInfo.credentialId, + credentialInfo: credential.credentialInfo, + timestamp, + revoked: isRevoked, + } satisfies AnonCredsRequestedPredicateMatch + }) + ) + ) + + // We only attach revoked state if non-revocation is requested. So if revoked is true it means + // the credential is not applicable to the proof request + if (options.filterByNonRevocationRequirements) { + credentialsForProofRequest.predicates[referent] = credentialsForProofRequest.predicates[referent].filter( + (r) => !r.revoked + ) + } + } + + return credentialsForProofRequest + } + + private async _selectCredentialsForRequest( + agentContext: AgentContext, + proofRequest: AnonCredsProofRequest, + options: AnonCredsGetCredentialsForProofRequestOptions + ): Promise { + const credentialsForRequest = await this._getCredentialsForRequest(agentContext, proofRequest, options) + + const selectedCredentials: AnonCredsSelectedCredentials = { + attributes: {}, + predicates: {}, + selfAttestedAttributes: {}, + } + + Object.keys(credentialsForRequest.attributes).forEach((attributeName) => { + const attributeArray = credentialsForRequest.attributes[attributeName] + + if (attributeArray.length === 0) { + throw new AriesFrameworkError('Unable to automatically select requested attributes.') + } + + selectedCredentials.attributes[attributeName] = attributeArray[0] + }) + + Object.keys(credentialsForRequest.predicates).forEach((attributeName) => { + if (credentialsForRequest.predicates[attributeName].length === 0) { + throw new AriesFrameworkError('Unable to automatically select requested predicates.') + } else { + selectedCredentials.predicates[attributeName] = credentialsForRequest.predicates[attributeName][0] + } + }) + + return selectedCredentials + } + + private async getCredentialsForProofRequestReferent( + agentContext: AgentContext, + proofRequest: AnonCredsProofRequest, + attributeReferent: string + ): Promise { + const holderService = agentContext.dependencyManager.resolve(AnonCredsHolderServiceSymbol) + + const credentials = await holderService.getCredentialsForProofRequest(agentContext, { + proofRequest, + attributeReferent, + }) + + return credentials + } + + /** + * Build schemas object needed to create and verify proof objects. + * + * Creates object with `{ schemaId: AnonCredsSchema }` mapping + * + * @param schemaIds List of schema ids + * @returns Object containing schemas for specified schema ids + * + */ + private async getSchemas(agentContext: AgentContext, schemaIds: Set) { + const registryService = agentContext.dependencyManager.resolve(AnonCredsRegistryService) + + const schemas: { [key: string]: AnonCredsSchema } = {} + + for (const schemaId of schemaIds) { + const schemaRegistry = registryService.getRegistryForIdentifier(agentContext, schemaId) + const schemaResult = await schemaRegistry.getSchema(agentContext, schemaId) + + if (!schemaResult.schema) { + throw new AriesFrameworkError(`Schema not found for id ${schemaId}: ${schemaResult.resolutionMetadata.message}`) + } + + schemas[schemaId] = schemaResult.schema + } + + return schemas + } + + /** + * Build credential definitions object needed to create and verify proof objects. + * + * Creates object with `{ credentialDefinitionId: AnonCredsCredentialDefinition }` mapping + * + * @param credentialDefinitionIds List of credential definition ids + * @returns Object containing credential definitions for specified credential definition ids + * + */ + private async getCredentialDefinitions(agentContext: AgentContext, credentialDefinitionIds: Set) { + const registryService = agentContext.dependencyManager.resolve(AnonCredsRegistryService) + + const credentialDefinitions: { [key: string]: AnonCredsCredentialDefinition } = {} + + for (const credentialDefinitionId of credentialDefinitionIds) { + const credentialDefinitionRegistry = registryService.getRegistryForIdentifier( + agentContext, + credentialDefinitionId + ) + + const credentialDefinitionResult = await credentialDefinitionRegistry.getCredentialDefinition( + agentContext, + credentialDefinitionId + ) + + if (!credentialDefinitionResult.credentialDefinition) { + throw new AriesFrameworkError( + `Credential definition not found for id ${credentialDefinitionId}: ${credentialDefinitionResult.resolutionMetadata.message}` + ) + } + + credentialDefinitions[credentialDefinitionId] = credentialDefinitionResult.credentialDefinition + } + + return credentialDefinitions + } + + private async getRevocationStatus( + agentContext: AgentContext, + proofRequest: AnonCredsProofRequest, + requestedItem: AnonCredsRequestedAttribute | AnonCredsRequestedPredicate, + credentialInfo: AnonCredsCredentialInfo + ) { + const requestNonRevoked = requestedItem.non_revoked ?? proofRequest.non_revoked + const credentialRevocationId = credentialInfo.credentialRevocationId + const revocationRegistryId = credentialInfo.revocationRegistryId + + // If revocation interval is not present or the credential is not revocable then we + // don't need to fetch the revocation status + if (!requestNonRevoked || !credentialRevocationId || !revocationRegistryId) { + return { isRevoked: undefined, timestamp: undefined } + } + + agentContext.config.logger.trace( + `Fetching credential revocation status for credential revocation id '${credentialRevocationId}' with revocation interval with from '${requestNonRevoked.from}' and to '${requestNonRevoked.to}'` + ) + + // Make sure the revocation interval follows best practices from Aries RFC 0441 + assertRevocationInterval(requestNonRevoked) + + const registryService = agentContext.dependencyManager.resolve(AnonCredsRegistryService) + const registry = registryService.getRegistryForIdentifier(agentContext, revocationRegistryId) + + const revocationStatusResult = await registry.getRevocationStatusList( + agentContext, + revocationRegistryId, + requestNonRevoked.to ?? Date.now() + ) + + if (!revocationStatusResult.revocationStatusList) { + throw new AriesFrameworkError( + `Could not retrieve revocation status list for revocation registry ${revocationRegistryId}: ${revocationStatusResult.resolutionMetadata.message}` + ) + } + + // Item is revoked when the value at the index is 1 + const isRevoked = revocationStatusResult.revocationStatusList.revocationList[parseInt(credentialRevocationId)] === 1 + + agentContext.config.logger.trace( + `Credential with credential revocation index '${credentialRevocationId}' is ${ + isRevoked ? '' : 'not ' + }revoked with revocation interval with to '${requestNonRevoked.to}' & from '${requestNonRevoked.from}'` + ) + + return { + isRevoked, + timestamp: revocationStatusResult.revocationStatusList.timestamp, + } + } + + /** + * Create indy proof from a given proof request and requested credential object. + * + * @param proofRequest The proof request to create the proof for + * @param requestedCredentials The requested credentials object specifying which credentials to use for the proof + * @returns indy proof object + */ + private async createProof( + agentContext: AgentContext, + proofRequest: AnonCredsProofRequest, + selectedCredentials: AnonCredsSelectedCredentials + ): Promise { + const holderService = agentContext.dependencyManager.resolve(AnonCredsHolderServiceSymbol) + + const credentialObjects = await Promise.all( + [...Object.values(selectedCredentials.attributes), ...Object.values(selectedCredentials.predicates)].map( + async (c) => c.credentialInfo ?? holderService.getCredential(agentContext, { credentialId: c.credentialId }) + ) + ) + + const schemas = await this.getSchemas(agentContext, new Set(credentialObjects.map((c) => c.schemaId))) + const credentialDefinitions = await this.getCredentialDefinitions( + agentContext, + new Set(credentialObjects.map((c) => c.credentialDefinitionId)) + ) + + const revocationRegistries = await this.getRevocationRegistriesForRequest( + agentContext, + proofRequest, + selectedCredentials + ) + + return await holderService.createProof(agentContext, { + proofRequest, + selectedCredentials, + schemas, + credentialDefinitions, + revocationRegistries, + }) + } + + private async getRevocationRegistriesForRequest( + agentContext: AgentContext, + proofRequest: AnonCredsProofRequest, + selectedCredentials: AnonCredsSelectedCredentials + ) { + const revocationRegistries: CreateProofOptions['revocationRegistries'] = {} + + try { + agentContext.config.logger.debug(`Retrieving revocation registries for proof request`, { + proofRequest, + selectedCredentials, + }) + + const referentCredentials = [] + + // Retrieve information for referents and push to single array + for (const [referent, selectedCredential] of Object.entries(selectedCredentials.attributes)) { + referentCredentials.push({ + referent, + credentialInfo: selectedCredential.credentialInfo, + nonRevoked: proofRequest.requested_attributes[referent].non_revoked ?? proofRequest.non_revoked, + }) + } + for (const [referent, selectedCredential] of Object.entries(selectedCredentials.predicates)) { + referentCredentials.push({ + referent, + credentialInfo: selectedCredential.credentialInfo, + nonRevoked: proofRequest.requested_predicates[referent].non_revoked ?? proofRequest.non_revoked, + }) + } + + for (const { referent, credentialInfo, nonRevoked } of referentCredentials) { + if (!credentialInfo) { + throw new AriesFrameworkError( + `Credential for referent '${referent} does not have credential info for revocation state creation` + ) + } + + // Prefer referent-specific revocation interval over global revocation interval + const credentialRevocationId = credentialInfo.credentialRevocationId + const revocationRegistryId = credentialInfo.revocationRegistryId + + // If revocation interval is present and the credential is revocable then create revocation state + if (nonRevoked && credentialRevocationId && revocationRegistryId) { + agentContext.config.logger.trace( + `Presentation is requesting proof of non revocation for referent '${referent}', creating revocation state for credential`, + { + nonRevoked, + credentialRevocationId, + revocationRegistryId, + } + ) + + // Make sure the revocation interval follows best practices from Aries RFC 0441 + assertRevocationInterval(nonRevoked) + + const registry = agentContext.dependencyManager + .resolve(AnonCredsRegistryService) + .getRegistryForIdentifier(agentContext, revocationRegistryId) + + // Fetch revocation registry definition if not in revocation registries list yet + if (!revocationRegistries[revocationRegistryId]) { + const { revocationRegistryDefinition, resolutionMetadata } = await registry.getRevocationRegistryDefinition( + agentContext, + revocationRegistryId + ) + if (!revocationRegistryDefinition) { + throw new AriesFrameworkError( + `Could not retrieve revocation registry definition for revocation registry ${revocationRegistryId}: ${resolutionMetadata.message}` + ) + } + + const { tailsLocation, tailsHash } = revocationRegistryDefinition.value + const { tailsFilePath } = await downloadTailsFile(agentContext, tailsLocation, tailsHash) + + // const tails = await this.indyUtilitiesService.downloadTails(tailsHash, tailsLocation) + revocationRegistries[revocationRegistryId] = { + definition: revocationRegistryDefinition, + tailsFilePath, + revocationStatusLists: {}, + } + } + + // TODO: can we check if the revocation status list is already fetched? We don't know which timestamp the query will return. This + // should probably be solved using caching + // Fetch the revocation status list + const { revocationStatusList, resolutionMetadata: statusListResolutionMetadata } = + await registry.getRevocationStatusList(agentContext, revocationRegistryId, nonRevoked.to ?? Date.now()) + if (!revocationStatusList) { + throw new AriesFrameworkError( + `Could not retrieve revocation status list for revocation registry ${revocationRegistryId}: ${statusListResolutionMetadata.message}` + ) + } + + revocationRegistries[revocationRegistryId].revocationStatusLists[revocationStatusList.timestamp] = + revocationStatusList + } + } + + agentContext.config.logger.debug(`Retrieved revocation registries for proof request`, { + revocationRegistries, + }) + + return revocationRegistries + } catch (error) { + agentContext.config.logger.error(`Error retrieving revocation registry for proof request`, { + error, + proofRequest, + selectedCredentials, + }) + + throw error + } + } + + private async getRevocationRegistriesForProof(agentContext: AgentContext, proof: AnonCredsProof) { + const revocationRegistries: VerifyProofOptions['revocationRegistries'] = {} + + for (const identifier of proof.identifiers) { + const revocationRegistryId = identifier.rev_reg_id + const timestamp = identifier.timestamp + + // Skip if no revocation registry id is present + if (!revocationRegistryId || !timestamp) continue + + const registry = agentContext.dependencyManager + .resolve(AnonCredsRegistryService) + .getRegistryForIdentifier(agentContext, revocationRegistryId) + + // Fetch revocation registry definition if not already fetched + if (!revocationRegistries[revocationRegistryId]) { + const { revocationRegistryDefinition, resolutionMetadata } = await registry.getRevocationRegistryDefinition( + agentContext, + revocationRegistryId + ) + if (!revocationRegistryDefinition) { + throw new AriesFrameworkError( + `Could not retrieve revocation registry definition for revocation registry ${revocationRegistryId}: ${resolutionMetadata.message}` + ) + } + + revocationRegistries[revocationRegistryId] = { + definition: revocationRegistryDefinition, + revocationStatusLists: {}, + } + } + + // Fetch revocation status list by timestamp if not already fetched + if (!revocationRegistries[revocationRegistryId].revocationStatusLists[timestamp]) { + const { revocationStatusList, resolutionMetadata: statusListResolutionMetadata } = + await registry.getRevocationStatusList(agentContext, revocationRegistryId, timestamp) + + if (!revocationStatusList) { + throw new AriesFrameworkError( + `Could not retrieve revocation status list for revocation registry ${revocationRegistryId}: ${statusListResolutionMetadata.message}` + ) + } + + revocationRegistries[revocationRegistryId].revocationStatusLists[timestamp] = revocationStatusList + } + } + + return revocationRegistries + } + + /** + * Returns an object of type {@link Attachment} for use in credential exchange messages. + * It looks up the correct format identifier and encodes the data as a base64 attachment. + * + * @param data The data to include in the attach object + * @param id the attach id from the formats component of the message + */ + private getFormatData(data: unknown, id: string): Attachment { + const attachment = new Attachment({ + id, + mimeType: 'application/json', + data: new AttachmentData({ + base64: JsonEncoder.toBase64(data), + }), + }) + + return attachment + } +} diff --git a/packages/anoncreds/src/formats/__tests__/LegacyIndyCredentialFormatService.test.ts b/packages/anoncreds/src/formats/__tests__/legacy-indy-format-services.test.ts similarity index 72% rename from packages/anoncreds/src/formats/__tests__/LegacyIndyCredentialFormatService.test.ts rename to packages/anoncreds/src/formats/__tests__/legacy-indy-format-services.test.ts index 2449c81124..60359bb3ae 100644 --- a/packages/anoncreds/src/formats/__tests__/LegacyIndyCredentialFormatService.test.ts +++ b/packages/anoncreds/src/formats/__tests__/legacy-indy-format-services.test.ts @@ -4,6 +4,8 @@ import { SigningProviderRegistry, KeyType, CredentialPreviewAttribute, + ProofExchangeRecord, + ProofState, } from '@aries-framework/core' import * as indySdk from 'indy-sdk' @@ -25,13 +27,14 @@ import { } from '../../services' import { AnonCredsRegistryService } from '../../services/registry/AnonCredsRegistryService' import { LegacyIndyCredentialFormatService } from '../LegacyIndyCredentialFormatService' +import { LegacyIndyProofFormatService } from '../LegacyIndyProofFormatService' const registry = new InMemoryAnonCredsRegistry() const anonCredsModuleConfig = new AnonCredsModuleConfig({ registries: [registry], }) -const agentConfig = getAgentConfig('LegacyIndyCredentialFormatServiceTest') +const agentConfig = getAgentConfig('LegacyIndyProofFormatServiceTest') const anonCredsRevocationService = new IndySdkRevocationService(indySdk) const anonCredsVerifierService = new IndySdkVerifierService(indySdk) const anonCredsHolderService = new IndySdkHolderService(anonCredsRevocationService, indySdk) @@ -50,8 +53,11 @@ const agentContext = getAgentContext({ }) const indyCredentialFormatService = new LegacyIndyCredentialFormatService() +const indyProofFormatService = new LegacyIndyProofFormatService() -describe('LegacyIndyCredentialFormatService', () => { +// We can split up these tests when we can use AnonCredsRS as a backend, but currently +// we need to have the link secrets etc in the wallet which is not so easy to do with Indy +describe('Legacy indy format services', () => { beforeEach(async () => { await wallet.createAndOpen(agentConfig.walletConfig) }) @@ -60,8 +66,8 @@ describe('LegacyIndyCredentialFormatService', () => { await wallet.delete() }) - 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) + test('issuance and verification flow starting from proposal without negotiation and without revocation', async () => { + // This is just so we don't have to register an actual indy did (as we don't have the indy did registrar configured) const key = await wallet.createKey({ keyType: KeyType.Ed25519 }) const indyDid = indyDidFromPublicKeyBase58(key.publicKeyBase58) @@ -220,5 +226,71 @@ describe('LegacyIndyCredentialFormatService', () => { credentialDefinitionId: credentialDefinitionState.credentialDefinitionId, }, }) + + 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', + }) + + const { attachment: proofProposalAttachment } = await indyProofFormatService.createProposal(agentContext, { + proofFormats: { + indy: { + 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, + }) + + await indyProofFormatService.processProposal(agentContext, { + attachment: proofProposalAttachment, + proofRecord: verifierProofRecord, + }) + + const { attachment: proofRequestAttachment } = await indyProofFormatService.acceptProposal(agentContext, { + proofRecord: verifierProofRecord, + proposalAttachment: proofProposalAttachment, + }) + + await indyProofFormatService.processRequest(agentContext, { + attachment: proofRequestAttachment, + proofRecord: holderProofRecord, + }) + + const { attachment: proofAttachment } = await indyProofFormatService.acceptRequest(agentContext, { + proofRecord: holderProofRecord, + requestAttachment: proofRequestAttachment, + proposalAttachment: proofProposalAttachment, + }) + + const isValid = await indyProofFormatService.processPresentation(agentContext, { + attachment: proofAttachment, + proofRecord: verifierProofRecord, + requestAttachment: proofRequestAttachment, + }) + + expect(isValid).toBe(true) }) }) diff --git a/packages/anoncreds/src/formats/index.ts b/packages/anoncreds/src/formats/index.ts new file mode 100644 index 0000000000..25f0a81917 --- /dev/null +++ b/packages/anoncreds/src/formats/index.ts @@ -0,0 +1,7 @@ +export * from './AnonCredsCredentialFormat' +export * from './LegacyIndyCredentialFormat' +export { LegacyIndyCredentialFormatService } from './LegacyIndyCredentialFormatService' + +export * from './AnonCredsProofFormat' +export * from './LegacyIndyProofFormat' +export { LegacyIndyProofFormatService } from './LegacyIndyProofFormatService' diff --git a/packages/anoncreds/src/index.ts b/packages/anoncreds/src/index.ts index 9ef264f501..ced98385f2 100644 --- a/packages/anoncreds/src/index.ts +++ b/packages/anoncreds/src/index.ts @@ -2,8 +2,9 @@ export * from './models' export * from './services' export * from './error' export * from './repository' +export * from './formats' + export { AnonCredsModule } from './AnonCredsModule' export { AnonCredsModuleConfig, AnonCredsModuleConfigOptions } from './AnonCredsModuleConfig' export { AnonCredsApi } from './AnonCredsApi' -export { LegacyIndyCredentialFormatService } from './formats/LegacyIndyCredentialFormatService' -export { AnonCredsRegistryService } from './services/registry/AnonCredsRegistryService' +export { AnonCredsCreateLinkSecretOptions } from './AnonCredsApiOptions' diff --git a/packages/anoncreds/src/models/AnonCredsCredentialProposal.ts b/packages/anoncreds/src/models/AnonCredsCredentialProposal.ts new file mode 100644 index 0000000000..928c26b5d5 --- /dev/null +++ b/packages/anoncreds/src/models/AnonCredsCredentialProposal.ts @@ -0,0 +1,111 @@ +import { Expose } from 'class-transformer' +import { IsOptional, IsString } from 'class-validator' + +export interface AnonCredsCredentialProposalOptions { + /** + * @deprecated Use `schemaIssuerId` instead. Only valid for legacy indy identifiers. + */ + schemaIssuerDid?: string + schemaIssuerId?: string + + schemaId?: string + schemaName?: string + schemaVersion?: string + credentialDefinitionId?: string + + /** + * @deprecated Use `issuerId` instead. Only valid for legacy indy identifiers. + */ + issuerDid?: string + issuerId?: string +} + +/** + * Class representing an AnonCreds credential proposal as defined in Aries RFC 0592 (and soon the new AnonCreds RFC) + */ +export class AnonCredsCredentialProposal { + public constructor(options: AnonCredsCredentialProposalOptions) { + if (options) { + this.schemaIssuerDid = options.schemaIssuerDid + this.schemaIssuerId = options.schemaIssuerId + this.schemaId = options.schemaId + this.schemaName = options.schemaName + this.schemaVersion = options.schemaVersion + this.credentialDefinitionId = options.credentialDefinitionId + this.issuerDid = options.issuerDid + this.issuerId = options.issuerId + } + } + + /** + * Filter to request credential based on a particular Schema issuer DID. + * + * May only be used with legacy indy identifiers + * + * @deprecated Use schemaIssuerId instead + */ + @Expose({ name: 'schema_issuer_did' }) + @IsString() + @IsOptional() + public schemaIssuerDid?: string + + /** + * Filter to request credential based on a particular Schema issuer DID. + */ + @Expose({ name: 'schema_issuer_id' }) + @IsString() + @IsOptional() + public schemaIssuerId?: string + + /** + * Filter to request credential based on a particular Schema. + */ + @Expose({ name: 'schema_id' }) + @IsString() + @IsOptional() + public schemaId?: string + + /** + * Filter to request credential based on a schema name. + */ + @Expose({ name: 'schema_name' }) + @IsString() + @IsOptional() + public schemaName?: string + + /** + * Filter to request credential based on a schema version. + */ + @Expose({ name: 'schema_version' }) + @IsString() + @IsOptional() + public schemaVersion?: string + + /** + * Filter to request credential based on a particular Credential Definition. + */ + @Expose({ name: 'cred_def_id' }) + @IsString() + @IsOptional() + public credentialDefinitionId?: string + + /** + * Filter to request a credential issued by the owner of a particular DID. + * + * May only be used with legacy indy identifiers + * + * @deprecated Use issuerId instead + */ + @Expose({ name: 'issuer_did' }) + @IsString() + @IsOptional() + public issuerDid?: string + + /** + * Filter to request a credential issued by the owner of a particular DID. + */ + @Expose({ name: 'issuer_id' }) + @IsString() + @IsOptional() + public issuerId?: string +} diff --git a/packages/anoncreds/src/models/AnonCredsProofRequest.ts b/packages/anoncreds/src/models/AnonCredsProofRequest.ts new file mode 100644 index 0000000000..34abfe3030 --- /dev/null +++ b/packages/anoncreds/src/models/AnonCredsProofRequest.ts @@ -0,0 +1,83 @@ +import type { AnonCredsRequestedPredicateOptions } from './AnonCredsRequestedPredicate' + +import { IndyRevocationInterval } from '@aries-framework/core' +import { Expose, Type } from 'class-transformer' +import { IsIn, IsInstance, IsOptional, IsString, ValidateNested } from 'class-validator' + +import { IsMap } from '../utils' + +import { AnonCredsRequestedAttribute } from './AnonCredsRequestedAttribute' +import { AnonCredsRequestedPredicate } from './AnonCredsRequestedPredicate' +import { AnonCredsRevocationInterval } from './AnonCredsRevocationInterval' + +export interface AnonCredsProofRequestOptions { + name: string + version: string + nonce: string + nonRevoked?: AnonCredsRevocationInterval + ver?: '1.0' | '2.0' + requestedAttributes?: Record + requestedPredicates?: Record +} + +/** + * Proof Request for AnonCreds based proof format + */ +export class AnonCredsProofRequest { + public constructor(options: AnonCredsProofRequestOptions) { + if (options) { + this.name = options.name + this.version = options.version + this.nonce = options.nonce + + this.requestedAttributes = new Map( + Object.entries(options.requestedAttributes ?? {}).map(([key, attribute]) => [ + key, + new AnonCredsRequestedAttribute(attribute), + ]) + ) + + this.requestedPredicates = new Map( + Object.entries(options.requestedPredicates ?? {}).map(([key, predicate]) => [ + key, + new AnonCredsRequestedPredicate(predicate), + ]) + ) + + this.nonRevoked = options.nonRevoked ? new AnonCredsRevocationInterval(options.nonRevoked) : undefined + this.ver = options.ver + } + } + + @IsString() + public name!: string + + @IsString() + public version!: string + + @IsString() + public nonce!: string + + @Expose({ name: 'requested_attributes' }) + @IsMap() + @ValidateNested({ each: true }) + @Type(() => AnonCredsRequestedAttribute) + public requestedAttributes!: Map + + @Expose({ name: 'requested_predicates' }) + @IsMap() + @ValidateNested({ each: true }) + @Type(() => AnonCredsRequestedPredicate) + public requestedPredicates!: Map + + @Expose({ name: 'non_revoked' }) + @ValidateNested() + @Type(() => IndyRevocationInterval) + @IsOptional() + @IsInstance(IndyRevocationInterval) + public nonRevoked?: IndyRevocationInterval + + @IsIn(['1.0', '2.0']) + @IsOptional() + public ver?: '1.0' | '2.0' +} diff --git a/packages/anoncreds/src/models/AnonCredsRequestedAttribute.ts b/packages/anoncreds/src/models/AnonCredsRequestedAttribute.ts new file mode 100644 index 0000000000..806f5f422b --- /dev/null +++ b/packages/anoncreds/src/models/AnonCredsRequestedAttribute.ts @@ -0,0 +1,39 @@ +import { Expose, Type } from 'class-transformer' +import { ArrayNotEmpty, IsArray, IsInstance, IsOptional, IsString, ValidateIf, ValidateNested } from 'class-validator' + +import { AnonCredsRestriction, AnonCredsRestrictionTransformer } from './AnonCredsRestriction' +import { AnonCredsRevocationInterval } from './AnonCredsRevocationInterval' + +export class AnonCredsRequestedAttribute { + public constructor(options: AnonCredsRequestedAttribute) { + if (options) { + this.name = options.name + this.names = options.names + this.nonRevoked = options.nonRevoked ? new AnonCredsRevocationInterval(options.nonRevoked) : undefined + this.restrictions = options.restrictions?.map((r) => new AnonCredsRestriction(r)) + } + } + + @IsString() + @ValidateIf((o: AnonCredsRequestedAttribute) => o.names === undefined) + public name?: string + + @IsArray() + @IsString({ each: true }) + @ValidateIf((o: AnonCredsRequestedAttribute) => o.name === undefined) + @ArrayNotEmpty() + public names?: string[] + + @Expose({ name: 'non_revoked' }) + @ValidateNested() + @IsInstance(AnonCredsRevocationInterval) + @Type(() => AnonCredsRevocationInterval) + @IsOptional() + public nonRevoked?: AnonCredsRevocationInterval + + @ValidateNested({ each: true }) + @Type(() => AnonCredsRestriction) + @IsOptional() + @AnonCredsRestrictionTransformer() + public restrictions?: AnonCredsRestriction[] +} diff --git a/packages/anoncreds/src/models/AnonCredsRequestedPredicate.ts b/packages/anoncreds/src/models/AnonCredsRequestedPredicate.ts new file mode 100644 index 0000000000..5f9f99ebc0 --- /dev/null +++ b/packages/anoncreds/src/models/AnonCredsRequestedPredicate.ts @@ -0,0 +1,53 @@ +import { Expose, Type } from 'class-transformer' +import { IsArray, IsIn, IsInstance, IsInt, IsOptional, IsString, ValidateNested } from 'class-validator' + +import { AnonCredsPredicateType, anonCredsPredicateType } from '../models' + +import { AnonCredsRestriction, AnonCredsRestrictionTransformer } from './AnonCredsRestriction' +import { AnonCredsRevocationInterval } from './AnonCredsRevocationInterval' + +export interface AnonCredsRequestedPredicateOptions { + name: string + // Also allow string value of the enum as input, to make it easier to use in the API + predicateType: AnonCredsPredicateType + predicateValue: number + nonRevoked?: AnonCredsRevocationInterval + restrictions?: AnonCredsRestriction[] +} + +export class AnonCredsRequestedPredicate { + public constructor(options: AnonCredsRequestedPredicateOptions) { + if (options) { + this.name = options.name + this.nonRevoked = options.nonRevoked ? new AnonCredsRevocationInterval(options.nonRevoked) : undefined + this.restrictions = options.restrictions?.map((r) => new AnonCredsRestriction(r)) + this.predicateType = options.predicateType as AnonCredsPredicateType + this.predicateValue = options.predicateValue + } + } + + @IsString() + public name!: string + + @Expose({ name: 'p_type' }) + @IsIn(anonCredsPredicateType) + public predicateType!: AnonCredsPredicateType + + @Expose({ name: 'p_value' }) + @IsInt() + public predicateValue!: number + + @Expose({ name: 'non_revoked' }) + @ValidateNested() + @Type(() => AnonCredsRevocationInterval) + @IsOptional() + @IsInstance(AnonCredsRevocationInterval) + public nonRevoked?: AnonCredsRevocationInterval + + @ValidateNested({ each: true }) + @Type(() => AnonCredsRestriction) + @IsOptional() + @IsArray() + @AnonCredsRestrictionTransformer() + public restrictions?: AnonCredsRestriction[] +} diff --git a/packages/anoncreds/src/models/AnonCredsRestriction.ts b/packages/anoncreds/src/models/AnonCredsRestriction.ts new file mode 100644 index 0000000000..def1fc70a2 --- /dev/null +++ b/packages/anoncreds/src/models/AnonCredsRestriction.ts @@ -0,0 +1,139 @@ +import { Exclude, Expose, Transform, TransformationType } from 'class-transformer' +import { IsOptional, IsString } from 'class-validator' + +export class AnonCredsRestriction { + public constructor(options: AnonCredsRestriction) { + if (options) { + this.schemaId = options.schemaId + this.schemaIssuerDid = options.schemaIssuerDid + this.schemaIssuerId = options.schemaIssuerId + this.schemaName = options.schemaName + this.schemaVersion = options.schemaVersion + this.issuerDid = options.issuerDid + this.issuerId = options.issuerId + this.credentialDefinitionId = options.credentialDefinitionId + this.attributeMarkers = options.attributeMarkers + this.attributeValues = options.attributeValues + } + } + + @Expose({ name: 'schema_id' }) + @IsOptional() + @IsString() + public schemaId?: string + + @Expose({ name: 'schema_issuer_did' }) + @IsOptional() + @IsString() + public schemaIssuerDid?: string + + @Expose({ name: 'schema_issuer_id' }) + @IsOptional() + @IsString() + public schemaIssuerId?: string + + @Expose({ name: 'schema_name' }) + @IsOptional() + @IsString() + public schemaName?: string + + @Expose({ name: 'schema_version' }) + @IsOptional() + @IsString() + public schemaVersion?: string + + @Expose({ name: 'issuer_did' }) + @IsOptional() + @IsString() + public issuerDid?: string + + @Expose({ name: 'issuer_id' }) + @IsOptional() + @IsString() + public issuerId?: string + + @Expose({ name: 'cred_def_id' }) + @IsOptional() + @IsString() + public credentialDefinitionId?: string + + @Exclude() + public attributeMarkers: Record = {} + + @Exclude() + public attributeValues: Record = {} +} + +/** + * Decorator that transforms attribute values and attribute markers. + * + * It will transform between the following JSON structure: + * ```json + * { + * "attr::test_prop::value": "test_value" + * "attr::test_prop::marker": "1 + * } + * ``` + * + * And the following AnonCredsRestriction: + * ```json + * { + * "attributeValues": { + * "test_prop": "test_value" + * }, + * "attributeMarkers": { + * "test_prop": true + * } + * } + * ``` + * + * @example + * class Example { + * AttributeFilterTransformer() + * public restrictions!: AnonCredsRestriction[] + * } + */ +export function AnonCredsRestrictionTransformer() { + return Transform(({ value: restrictions, type }) => { + switch (type) { + case TransformationType.CLASS_TO_PLAIN: + if (restrictions && Array.isArray(restrictions)) { + for (const restriction of restrictions) { + const r = restriction as AnonCredsRestriction + + for (const [attributeName, attributeValue] of Object.entries(r.attributeValues)) { + restriction[`attr::${attributeName}::value`] = attributeValue + } + + for (const [attributeName] of Object.entries(r.attributeMarkers)) { + restriction[`attr::${attributeName}::marker`] = '1' + } + } + } + + return restrictions + + case TransformationType.PLAIN_TO_CLASS: + if (restrictions && Array.isArray(restrictions)) { + for (const restriction of restrictions) { + const r = restriction as AnonCredsRestriction + + for (const [attributeName, attributeValue] of Object.entries(r)) { + const match = new RegExp('^attr::([^:]+)::(value|marker)$').exec(attributeName) + + if (match && match[2] === 'marker' && attributeValue === '1') { + r.attributeMarkers[match[1]] = true + delete restriction[attributeName] + } else if (match && match[2] === 'value') { + r.attributeValues[match[1]] = attributeValue + delete restriction[attributeName] + } + } + } + } + return restrictions + default: + return restrictions + } + }) +} diff --git a/packages/anoncreds/src/models/AnonCredsRevocationInterval.ts b/packages/anoncreds/src/models/AnonCredsRevocationInterval.ts new file mode 100644 index 0000000000..0ae0160616 --- /dev/null +++ b/packages/anoncreds/src/models/AnonCredsRevocationInterval.ts @@ -0,0 +1,18 @@ +import { IsInt, IsOptional } from 'class-validator' + +export class AnonCredsRevocationInterval { + public constructor(options: AnonCredsRevocationInterval) { + if (options) { + this.from = options.from + this.to = options.to + } + } + + @IsInt() + @IsOptional() + public from?: number + + @IsInt() + @IsOptional() + public to?: number +} diff --git a/packages/anoncreds/src/models/__tests__/AnonCredsRestriction.test.ts b/packages/anoncreds/src/models/__tests__/AnonCredsRestriction.test.ts new file mode 100644 index 0000000000..a3d02ab549 --- /dev/null +++ b/packages/anoncreds/src/models/__tests__/AnonCredsRestriction.test.ts @@ -0,0 +1,80 @@ +import { JsonTransformer } from '@aries-framework/core' +import { Type } from 'class-transformer' +import { IsArray } from 'class-validator' + +import { AnonCredsRestriction, AnonCredsRestrictionTransformer } from '../AnonCredsRestriction' + +// We need to add the transformer class to the wrapper +class Wrapper { + public constructor(options: Wrapper) { + if (options) { + this.restrictions = options.restrictions + } + } + + @Type(() => AnonCredsRestriction) + @IsArray() + @AnonCredsRestrictionTransformer() + public restrictions!: AnonCredsRestriction[] +} + +describe('AnonCredsRestriction', () => { + test('parses attribute values and markers', () => { + const anonCredsRestrictions = JsonTransformer.fromJSON( + { + restrictions: [ + { + 'attr::test_prop::value': 'test_value', + 'attr::test_prop2::value': 'test_value2', + 'attr::test_prop::marker': '1', + 'attr::test_prop2::marker': '1', + }, + ], + }, + Wrapper + ) + + expect(anonCredsRestrictions).toEqual({ + restrictions: [ + { + attributeValues: { + test_prop: 'test_value', + test_prop2: 'test_value2', + }, + attributeMarkers: { + test_prop: true, + test_prop2: true, + }, + }, + ], + }) + }) + + test('transforms attributeValues and attributeMarkers to json', () => { + const restrictions = new Wrapper({ + restrictions: [ + new AnonCredsRestriction({ + attributeMarkers: { + test_prop: true, + test_prop2: true, + }, + attributeValues: { + test_prop: 'test_value', + test_prop2: 'test_value2', + }, + }), + ], + }) + + expect(JsonTransformer.toJSON(restrictions)).toMatchObject({ + restrictions: [ + { + 'attr::test_prop::value': 'test_value', + 'attr::test_prop2::value': 'test_value2', + 'attr::test_prop::marker': '1', + 'attr::test_prop2::marker': '1', + }, + ], + }) + }) +}) diff --git a/packages/anoncreds/src/models/exchange.ts b/packages/anoncreds/src/models/exchange.ts index b0e960afb8..7ec87b9ec7 100644 --- a/packages/anoncreds/src/models/exchange.ts +++ b/packages/anoncreds/src/models/exchange.ts @@ -1,3 +1,6 @@ +export const anonCredsPredicateType = ['>=', '>', '<=', '<'] as const +export type AnonCredsPredicateType = (typeof anonCredsPredicateType)[number] + export interface AnonCredsProofRequestRestriction { schema_id?: string schema_issuer_id?: string @@ -62,7 +65,8 @@ export interface AnonCredsProof { encoded: string } > - revealed_attr_groups: Record< + // revealed_attr_groups is only defined if there's a requested attribute using `names` + revealed_attr_groups?: Record< string, { sub_proof_index: number @@ -93,29 +97,27 @@ export interface AnonCredsProof { }> } +export interface AnonCredsRequestedAttribute { + name?: string + names?: string[] + restrictions?: AnonCredsProofRequestRestriction[] + non_revoked?: AnonCredsNonRevokedInterval +} + +export interface AnonCredsRequestedPredicate { + name: string + p_type: AnonCredsPredicateType + p_value: number + restrictions?: AnonCredsProofRequestRestriction[] + non_revoked?: AnonCredsNonRevokedInterval +} + export interface AnonCredsProofRequest { name: string version: string nonce: string - requested_attributes: Record< - string, - { - name?: string - names?: string[] - restrictions?: AnonCredsProofRequestRestriction[] - non_revoked?: AnonCredsNonRevokedInterval - } - > - requested_predicates: Record< - string, - { - name: string - p_type: '>=' | '>' | '<=' | '<' - p_value: number - restrictions?: AnonCredsProofRequestRestriction[] - non_revoked?: AnonCredsNonRevokedInterval - } - > + requested_attributes: Record + requested_predicates: Record non_revoked?: AnonCredsNonRevokedInterval ver?: '1.0' | '2.0' } diff --git a/packages/anoncreds/src/models/internal.ts b/packages/anoncreds/src/models/internal.ts index 27d476ebb3..39452f736a 100644 --- a/packages/anoncreds/src/models/internal.ts +++ b/packages/anoncreds/src/models/internal.ts @@ -9,7 +9,7 @@ export interface AnonCredsCredentialInfo { credentialRevocationId?: string | undefined } -export interface AnonCredsRequestedAttribute { +export interface AnonCredsRequestedAttributeMatch { credentialId: string timestamp?: number revealed: boolean @@ -17,16 +17,16 @@ export interface AnonCredsRequestedAttribute { revoked?: boolean } -export interface AnonCredsRequestedPredicate { +export interface AnonCredsRequestedPredicateMatch { credentialId: string timestamp?: number credentialInfo: AnonCredsCredentialInfo revoked?: boolean } -export interface AnonCredsRequestedCredentials { - requestedAttributes?: Record - requestedPredicates?: Record +export interface AnonCredsSelectedCredentials { + attributes: Record + predicates: Record selfAttestedAttributes: Record } diff --git a/packages/anoncreds/src/services/AnonCredsHolderServiceOptions.ts b/packages/anoncreds/src/services/AnonCredsHolderServiceOptions.ts index 747e3fcfed..6ed4db9f4a 100644 --- a/packages/anoncreds/src/services/AnonCredsHolderServiceOptions.ts +++ b/packages/anoncreds/src/services/AnonCredsHolderServiceOptions.ts @@ -1,7 +1,7 @@ import type { AnonCredsCredentialInfo, AnonCredsCredentialRequestMetadata, - AnonCredsRequestedCredentials, + AnonCredsSelectedCredentials, } from '../models' import type { AnonCredsCredential, @@ -24,7 +24,7 @@ export interface AnonCredsAttributeInfo { export interface CreateProofOptions { proofRequest: AnonCredsProofRequest - requestedCredentials: AnonCredsRequestedCredentials + selectedCredentials: AnonCredsSelectedCredentials schemas: { [schemaId: string]: AnonCredsSchema } @@ -37,7 +37,7 @@ export interface CreateProofOptions { tailsFilePath: string definition: AnonCredsRevocationRegistryDefinition revocationStatusLists: { - [timestamp: string]: AnonCredsRevocationStatusList + [timestamp: number]: AnonCredsRevocationStatusList } } } diff --git a/packages/anoncreds/src/services/AnonCredsVerifierService.ts b/packages/anoncreds/src/services/AnonCredsVerifierService.ts index 00e2a5670d..f0ffdf1e91 100644 --- a/packages/anoncreds/src/services/AnonCredsVerifierService.ts +++ b/packages/anoncreds/src/services/AnonCredsVerifierService.ts @@ -1,9 +1,10 @@ import type { VerifyProofOptions } from './AnonCredsVerifierServiceOptions' +import type { AgentContext } from '@aries-framework/core' export const AnonCredsVerifierServiceSymbol = Symbol('AnonCredsVerifierService') export interface AnonCredsVerifierService { // TODO: do we want to extend the return type with more info besides a boolean. // If the value is false it would be nice to have some extra contexts about why it failed - verifyProof(options: VerifyProofOptions): Promise + verifyProof(agentContext: AgentContext, options: VerifyProofOptions): Promise } diff --git a/packages/anoncreds/src/services/AnonCredsVerifierServiceOptions.ts b/packages/anoncreds/src/services/AnonCredsVerifierServiceOptions.ts index 85593764af..1bdd959f15 100644 --- a/packages/anoncreds/src/services/AnonCredsVerifierServiceOptions.ts +++ b/packages/anoncreds/src/services/AnonCredsVerifierServiceOptions.ts @@ -15,7 +15,7 @@ export interface VerifyProofOptions { credentialDefinitions: { [credentialDefinitionId: string]: AnonCredsCredentialDefinition } - revocationStates: { + revocationRegistries: { [revocationRegistryDefinitionId: string]: { definition: AnonCredsRevocationRegistryDefinition // NOTE: the verifier only needs the accumulator, not the whole state of the revocation registry diff --git a/packages/anoncreds/src/utils/__tests__/areRequestsEqual.test.ts b/packages/anoncreds/src/utils/__tests__/areRequestsEqual.test.ts new file mode 100644 index 0000000000..51f9c3317e --- /dev/null +++ b/packages/anoncreds/src/utils/__tests__/areRequestsEqual.test.ts @@ -0,0 +1,419 @@ +import type { AnonCredsProofRequest } from '../../models' + +import { areAnonCredsProofRequestsEqual } from '../areRequestsEqual' + +const proofRequest = { + name: 'Proof Request', + version: '1.0.0', + nonce: 'nonce', + ver: '1.0', + non_revoked: {}, + requested_attributes: { + a: { + names: ['name1', 'name2'], + restrictions: [ + { + cred_def_id: 'cred_def_id1', + }, + { + schema_id: 'schema_id', + }, + ], + }, + }, + requested_predicates: { + p: { + name: 'Hello', + p_type: '<', + p_value: 10, + restrictions: [ + { + cred_def_id: 'string2', + }, + { + cred_def_id: 'string', + }, + ], + }, + }, +} satisfies AnonCredsProofRequest + +describe('util | areAnonCredsProofRequestsEqual', () => { + test('does not compare name, ver, version and nonce', () => { + expect( + areAnonCredsProofRequestsEqual(proofRequest, { + ...proofRequest, + name: 'Proof Request 2', + version: '2.0.0', + nonce: 'nonce2', + ver: '2.0', + }) + ).toBe(true) + }) + + test('check top level non_revocation interval', () => { + // empty object is semantically equal to undefined + expect( + areAnonCredsProofRequestsEqual(proofRequest, { + ...proofRequest, + non_revoked: {}, + }) + ).toBe(true) + + // properties inside object are different + expect( + areAnonCredsProofRequestsEqual( + { + ...proofRequest, + non_revoked: { + to: 5, + }, + }, + { + ...proofRequest, + non_revoked: { + from: 5, + }, + } + ) + ).toBe(false) + + // One has non_revoked, other doesn't + expect( + areAnonCredsProofRequestsEqual(proofRequest, { + ...proofRequest, + non_revoked: { + from: 5, + }, + }) + ).toBe(false) + }) + + test('ignores attribute group name differences', () => { + expect( + areAnonCredsProofRequestsEqual(proofRequest, { + ...proofRequest, + requested_attributes: { + b: proofRequest.requested_attributes.a, + }, + }) + ).toBe(true) + }) + + test('ignores attribute restriction order', () => { + expect( + areAnonCredsProofRequestsEqual(proofRequest, { + ...proofRequest, + requested_attributes: { + a: { + ...proofRequest.requested_attributes.a, + restrictions: [...proofRequest.requested_attributes.a.restrictions].reverse(), + }, + }, + }) + ).toBe(true) + }) + + test('ignores attribute restriction undefined vs empty array', () => { + expect( + areAnonCredsProofRequestsEqual( + { + ...proofRequest, + requested_attributes: { + a: { + ...proofRequest.requested_attributes.a, + restrictions: undefined, + }, + }, + }, + { + ...proofRequest, + requested_attributes: { + a: { + ...proofRequest.requested_attributes.a, + restrictions: [], + }, + }, + } + ) + ).toBe(true) + }) + + test('ignores attribute names order', () => { + expect( + areAnonCredsProofRequestsEqual(proofRequest, { + ...proofRequest, + requested_attributes: { + a: { + ...proofRequest.requested_attributes.a, + names: ['name2', 'name1'], + }, + }, + }) + ).toBe(true) + }) + + test('checks attribute non_revocation interval', () => { + // empty object is semantically equal to undefined + expect( + areAnonCredsProofRequestsEqual(proofRequest, { + ...proofRequest, + requested_attributes: { + a: { + ...proofRequest.requested_attributes.a, + non_revoked: {}, + }, + }, + }) + ).toBe(true) + + // properties inside object are different + expect( + areAnonCredsProofRequestsEqual( + { + ...proofRequest, + requested_attributes: { + a: { + ...proofRequest.requested_attributes.a, + non_revoked: { + to: 5, + }, + }, + }, + }, + { + ...proofRequest, + requested_attributes: { + a: { + ...proofRequest.requested_attributes.a, + non_revoked: { + from: 5, + }, + }, + }, + } + ) + ).toBe(false) + + // One has non_revoked, other doesn't + expect( + areAnonCredsProofRequestsEqual(proofRequest, { + ...proofRequest, + requested_attributes: { + a: { + ...proofRequest.requested_attributes.a, + non_revoked: { + from: 5, + }, + }, + }, + }) + ).toBe(false) + }) + + test('checks attribute restriction differences', () => { + expect( + areAnonCredsProofRequestsEqual(proofRequest, { + ...proofRequest, + requested_attributes: { + a: { + ...proofRequest.requested_attributes.a, + restrictions: [ + { + cred_def_id: 'cred_def_id1', + }, + { + cred_def_id: 'cred_def_id2', + }, + ], + }, + }, + }) + ).toBe(false) + }) + + test('checks attribute name differences', () => { + expect( + areAnonCredsProofRequestsEqual(proofRequest, { + ...proofRequest, + requested_attributes: { + a: { + ...proofRequest.requested_attributes.a, + names: ['name3'], + }, + }, + }) + ).toBe(false) + + expect( + areAnonCredsProofRequestsEqual(proofRequest, { + ...proofRequest, + requested_attributes: { + a: { + ...proofRequest.requested_attributes.a, + name: 'name3', + names: undefined, + }, + }, + }) + ).toBe(false) + }) + + test('ignores predicate group name differences', () => { + expect( + areAnonCredsProofRequestsEqual(proofRequest, { + ...proofRequest, + requested_predicates: { + a: proofRequest.requested_predicates.p, + }, + }) + ).toBe(true) + }) + + test('ignores predicate restriction order', () => { + expect( + areAnonCredsProofRequestsEqual(proofRequest, { + ...proofRequest, + requested_predicates: { + p: { + ...proofRequest.requested_predicates.p, + restrictions: [...proofRequest.requested_predicates.p.restrictions].reverse(), + }, + }, + }) + ).toBe(true) + }) + + test('ignores predicate restriction undefined vs empty array', () => { + expect( + areAnonCredsProofRequestsEqual( + { + ...proofRequest, + requested_predicates: { + p: { + ...proofRequest.requested_predicates.p, + restrictions: undefined, + }, + }, + }, + { + ...proofRequest, + requested_predicates: { + p: { + ...proofRequest.requested_predicates.p, + restrictions: [], + }, + }, + } + ) + ).toBe(true) + }) + + test('checks predicate restriction differences', () => { + expect( + areAnonCredsProofRequestsEqual(proofRequest, { + ...proofRequest, + requested_attributes: { + p: { + ...proofRequest.requested_predicates.p, + restrictions: [ + { + cred_def_id: 'cred_def_id1', + }, + { + cred_def_id: 'cred_def_id2', + }, + ], + }, + }, + }) + ).toBe(false) + }) + + test('checks predicate name differences', () => { + expect( + areAnonCredsProofRequestsEqual(proofRequest, { + ...proofRequest, + requested_predicates: { + p: { + ...proofRequest.requested_predicates.p, + name: 'name3', + }, + }, + }) + ).toBe(false) + }) + + test('checks predicate non_revocation interval', () => { + // empty object is semantically equal to undefined + expect( + areAnonCredsProofRequestsEqual(proofRequest, { + ...proofRequest, + requested_predicates: { + p: { + ...proofRequest.requested_predicates.p, + non_revoked: {}, + }, + }, + }) + ).toBe(true) + + // properties inside object are different + expect( + areAnonCredsProofRequestsEqual( + { + ...proofRequest, + requested_predicates: { + p: { + ...proofRequest.requested_predicates.p, + non_revoked: { + to: 5, + }, + }, + }, + }, + { + ...proofRequest, + requested_predicates: { + p: { + ...proofRequest.requested_predicates.p, + non_revoked: { + from: 5, + }, + }, + }, + } + ) + ).toBe(false) + + // One has non_revoked, other doesn't + expect( + areAnonCredsProofRequestsEqual(proofRequest, { + ...proofRequest, + requested_predicates: { + p: { + ...proofRequest.requested_predicates.p, + non_revoked: { + from: 5, + }, + }, + }, + }) + ).toBe(false) + }) + + test('checks predicate p_type and p_value', () => { + expect( + areAnonCredsProofRequestsEqual(proofRequest, { + ...proofRequest, + requested_predicates: { + p: { + ...proofRequest.requested_predicates.p, + p_type: '<', + p_value: 134134, + }, + }, + }) + ).toBe(false) + }) +}) diff --git a/packages/anoncreds/src/utils/__tests__/credential.test.ts b/packages/anoncreds/src/utils/__tests__/credential.test.ts index 0b81afe881..f75598bb3c 100644 --- a/packages/anoncreds/src/utils/__tests__/credential.test.ts +++ b/packages/anoncreds/src/utils/__tests__/credential.test.ts @@ -1,6 +1,10 @@ import { CredentialPreviewAttribute } from '@aries-framework/core' -import { assertCredentialValuesMatch, checkValidEncoding, convertAttributesToCredentialValues } from '../credential' +import { + assertCredentialValuesMatch, + checkValidCredentialValueEncoding, + convertAttributesToCredentialValues, +} from '../credential' /** * Sample test cases for encoding/decoding of verifiable credential claims - Aries RFCs 0036 and 0037 @@ -219,7 +223,7 @@ describe('Utils | Credentials', () => { ) test.each(testEntries)('returns true for valid encoding %s', (_, raw, encoded) => { - expect(checkValidEncoding(raw, encoded)).toEqual(true) + expect(checkValidCredentialValueEncoding(raw, encoded)).toEqual(true) }) }) }) diff --git a/packages/anoncreds/src/utils/__tests__/hasDuplicateGroupNames.test.ts b/packages/anoncreds/src/utils/__tests__/hasDuplicateGroupNames.test.ts new file mode 100644 index 0000000000..4e7bab2ddd --- /dev/null +++ b/packages/anoncreds/src/utils/__tests__/hasDuplicateGroupNames.test.ts @@ -0,0 +1,70 @@ +import type { AnonCredsProofRequest } from '../../models' + +import { hasDuplicateGroupsNamesInProofRequest } from '../hasDuplicateGroupNames' + +const credentialDefinitionId = '9vPXgSpQJPkJEALbLXueBp:3:CL:57753:tag1' + +describe('util | hasDuplicateGroupsNamesInProofRequest', () => { + describe('assertNoDuplicateGroupsNamesInProofRequest', () => { + test('attribute names match', () => { + const proofRequest = { + name: 'proof-request', + version: '1.0', + nonce: 'testtesttest12345', + requested_attributes: { + age1: { + name: 'age', + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + age2: { + name: 'age', + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + }, + requested_predicates: {}, + } satisfies AnonCredsProofRequest + + expect(hasDuplicateGroupsNamesInProofRequest(proofRequest)).toBe(false) + }) + + test('attribute names match with predicates name', () => { + const proofRequest = { + name: 'proof-request', + version: '1.0', + nonce: 'testtesttest12345', + requested_attributes: { + attrib: { + name: 'age', + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + }, + requested_predicates: { + predicate: { + name: 'age', + p_type: '>=', + p_value: 50, + restrictions: [ + { + cred_def_id: credentialDefinitionId, + }, + ], + }, + }, + } satisfies AnonCredsProofRequest + + expect(hasDuplicateGroupsNamesInProofRequest(proofRequest)).toBe(true) + }) + }) +}) diff --git a/packages/anoncreds/src/utils/__tests__/revocationInterval.test.ts b/packages/anoncreds/src/utils/__tests__/revocationInterval.test.ts new file mode 100644 index 0000000000..c95e8c70f6 --- /dev/null +++ b/packages/anoncreds/src/utils/__tests__/revocationInterval.test.ts @@ -0,0 +1,37 @@ +import { assertRevocationInterval } from '../../utils' + +describe('assertRevocationInterval', () => { + test("throws if no 'to' value is specified", () => { + expect(() => + assertRevocationInterval({ + from: 10, + }) + ).toThrow() + }) + + test("throws if a 'from' value is specified and it is different from 'to'", () => { + expect(() => + assertRevocationInterval({ + to: 5, + from: 10, + }) + ).toThrow() + }) + + test('does not throw if only to is provided', () => { + expect(() => + assertRevocationInterval({ + to: 5, + }) + ).not.toThrow() + }) + + test('does not throw if from and to are equal', () => { + expect(() => + assertRevocationInterval({ + to: 10, + from: 10, + }) + ).not.toThrow() + }) +}) diff --git a/packages/anoncreds/src/utils/__tests__/sortRequestedCredentialsMatches.test.ts b/packages/anoncreds/src/utils/__tests__/sortRequestedCredentialsMatches.test.ts new file mode 100644 index 0000000000..0bd658a646 --- /dev/null +++ b/packages/anoncreds/src/utils/__tests__/sortRequestedCredentialsMatches.test.ts @@ -0,0 +1,57 @@ +import type { AnonCredsCredentialInfo, AnonCredsRequestedAttributeMatch } from '../../models' + +import { sortRequestedCredentialsMatches } from '../sortRequestedCredentialsMatches' + +const credentialInfo = {} as unknown as AnonCredsCredentialInfo + +const credentials: AnonCredsRequestedAttributeMatch[] = [ + { + credentialId: '1', + revealed: true, + revoked: true, + credentialInfo, + }, + { + credentialId: '2', + revealed: true, + revoked: undefined, + credentialInfo, + }, + { + credentialId: '3', + revealed: true, + revoked: false, + credentialInfo, + }, + { + credentialId: '4', + revealed: true, + revoked: false, + credentialInfo, + }, + { + credentialId: '5', + revealed: true, + revoked: true, + credentialInfo, + }, + { + credentialId: '6', + revealed: true, + revoked: undefined, + credentialInfo, + }, +] + +describe('sortRequestedCredentialsMatches', () => { + test('sorts the credentials', () => { + expect(sortRequestedCredentialsMatches(credentials)).toEqual([ + credentials[1], + credentials[5], + credentials[2], + credentials[3], + credentials[0], + credentials[4], + ]) + }) +}) diff --git a/packages/anoncreds/src/utils/areRequestsEqual.ts b/packages/anoncreds/src/utils/areRequestsEqual.ts new file mode 100644 index 0000000000..759312cf87 --- /dev/null +++ b/packages/anoncreds/src/utils/areRequestsEqual.ts @@ -0,0 +1,156 @@ +import type { AnonCredsNonRevokedInterval, AnonCredsProofRequest, AnonCredsProofRequestRestriction } from '../models' + +// Copied from the core package so we don't have to export these silly utils. We should probably move these to a separate package. +// eslint-disable-next-line @typescript-eslint/no-explicit-any +function areObjectsEqual(a: any, b: any): boolean { + if (typeof a == 'object' && a != null && typeof b == 'object' && b != null) { + if (Object.keys(a).length !== Object.keys(b).length) return false + for (const key in a) { + if (!(key in b) || !areObjectsEqual(a[key], b[key])) { + return false + } + } + for (const key in b) { + if (!(key in a) || !areObjectsEqual(b[key], a[key])) { + return false + } + } + return true + } else { + return a === b + } +} + +/** + * Checks whether two `names` arrays are equal. The order of the names doesn't matter. + */ +function areNamesEqual(namesA: string[] | undefined, namesB: string[] | undefined) { + if (namesA === undefined) return namesB === undefined || namesB.length === 0 + if (namesB === undefined) return namesA.length === 0 + + // Check if there are any duplicates + if (new Set(namesA).size !== namesA.length || new Set(namesB).size !== namesB.length) return false + + // Check if the number of names is equal between A & B + if (namesA.length !== namesB.length) return false + + return namesA.every((a) => namesB.includes(a)) +} + +/** + * Checks whether two proof requests are semantically equal. The `name`, `version` and `nonce`, `ver` fields are ignored. + * In addition the group names don't have to be the same between the different requests. + */ +export function areAnonCredsProofRequestsEqual( + requestA: AnonCredsProofRequest, + requestB: AnonCredsProofRequest +): boolean { + // Check if the top-level non-revocation interval is equal + if (!isNonRevokedEqual(requestA.non_revoked, requestB.non_revoked)) return false + + const attributeAList = Object.values(requestA.requested_attributes) + const attributeBList = Object.values(requestB.requested_attributes) + + // Check if the number of attribute groups is equal in both requests + if (attributeAList.length !== attributeBList.length) return false + + // Check if all attribute groups in A are also in B + const attributesMatch = attributeAList.every((a) => { + // find an attribute in B that matches this attribute + const bIndex = attributeBList.findIndex((b) => { + return ( + b.name === a.name && + areNamesEqual(a.names, b.names) && + isNonRevokedEqual(a.non_revoked, b.non_revoked) && + areRestrictionsEqual(a.restrictions, b.restrictions) + ) + }) + + // Match found + if (bIndex !== -1) { + attributeBList.splice(bIndex, 1) + return true + } + + // Match not found + return false + }) + + if (!attributesMatch) return false + + const predicatesA = Object.values(requestA.requested_predicates) + const predicatesB = Object.values(requestB.requested_predicates) + + if (predicatesA.length !== predicatesB.length) return false + const predicatesMatch = predicatesA.every((a) => { + // find a predicate in B that matches this predicate + const bIndex = predicatesB.findIndex((b) => { + return ( + a.name === b.name && + a.p_type === b.p_type && + a.p_value === b.p_value && + isNonRevokedEqual(a.non_revoked, b.non_revoked) && + areRestrictionsEqual(a.restrictions, b.restrictions) + ) + }) + + if (bIndex !== -1) { + predicatesB.splice(bIndex, 1) + return true + } + + return false + }) + + if (!predicatesMatch) return false + + return true +} + +/** + * Checks whether two non-revocation intervals are semantically equal. They are considered equal if: + * - Both are undefined + * - Both are empty objects + * - One if undefined and the other is an empty object + * - Both have the same from and to values + */ +function isNonRevokedEqual( + nonRevokedA: AnonCredsNonRevokedInterval | undefined, + nonRevokedB: AnonCredsNonRevokedInterval | undefined +) { + // Having an empty non-revoked object is the same as not having one + if (nonRevokedA === undefined) + return nonRevokedB === undefined || (nonRevokedB.from === undefined && nonRevokedB.to === undefined) + if (nonRevokedB === undefined) return nonRevokedA.from === undefined && nonRevokedA.to === undefined + + return nonRevokedA.from === nonRevokedB.from && nonRevokedA.to === nonRevokedB.to +} + +/** + * Check if two restriction lists are equal. The order of the restrictions does not matter. + */ +function areRestrictionsEqual( + restrictionsA: AnonCredsProofRequestRestriction[] | undefined, + restrictionsB: AnonCredsProofRequestRestriction[] | undefined +) { + // Having an undefined restrictions property or an empty array is the same + if (restrictionsA === undefined) return restrictionsB === undefined || restrictionsB.length === 0 + if (restrictionsB === undefined) return restrictionsA.length === 0 + + // Clone array to not modify input object + const bList = [...restrictionsB] + + // Check if all restrictions in A are also in B + return restrictionsA.every((a) => { + const bIndex = restrictionsB.findIndex((b) => areObjectsEqual(a, b)) + + // Match found + if (bIndex !== -1) { + bList.splice(bIndex, 1) + return true + } + + // Match not found + return false + }) +} diff --git a/packages/anoncreds/src/utils/createRequestFromPreview.ts b/packages/anoncreds/src/utils/createRequestFromPreview.ts new file mode 100644 index 0000000000..d1738d000e --- /dev/null +++ b/packages/anoncreds/src/utils/createRequestFromPreview.ts @@ -0,0 +1,89 @@ +import type { + AnonCredsPresentationPreviewAttribute, + AnonCredsPresentationPreviewPredicate, +} from '../formats/AnonCredsProofFormat' +import type { AnonCredsProofRequest } from '../models' + +import { utils } from '@aries-framework/core' + +export function createRequestFromPreview({ + name, + version, + nonce, + attributes, + predicates, +}: { + name: string + version: string + nonce: string + attributes: AnonCredsPresentationPreviewAttribute[] + predicates: AnonCredsPresentationPreviewPredicate[] +}): AnonCredsProofRequest { + const proofRequest: AnonCredsProofRequest = { + name, + version, + nonce, + requested_attributes: {}, + requested_predicates: {}, + } + + /** + * Create mapping of attributes by referent. This required the + * attributes to come from the same credential. + * @see https://github.com/hyperledger/aries-rfcs/blob/master/features/0037-present-proof/README.md#referent + * + * { + * "referent1": [Attribute1, Attribute2], + * "referent2": [Attribute3] + * } + */ + const attributesByReferent: Record = {} + for (const proposedAttributes of attributes ?? []) { + const referent = proposedAttributes.referent ?? utils.uuid() + + const referentAttributes = attributesByReferent[referent] + + // Referent key already exist, add to list + if (referentAttributes) { + referentAttributes.push(proposedAttributes) + } + + // Referent key does not exist yet, create new entry + else { + attributesByReferent[referent] = [proposedAttributes] + } + } + + // Transform attributes by referent to requested attributes + for (const [referent, proposedAttributes] of Object.entries(attributesByReferent)) { + // Either attributeName or attributeNames will be undefined + const attributeName = proposedAttributes.length == 1 ? proposedAttributes[0].name : undefined + const attributeNames = proposedAttributes.length > 1 ? proposedAttributes.map((a) => a.name) : undefined + + proofRequest.requested_attributes[referent] = { + name: attributeName, + names: attributeNames, + restrictions: [ + { + cred_def_id: proposedAttributes[0].credentialDefinitionId, + }, + ], + } + } + + // Transform proposed predicates to requested predicates + for (const proposedPredicate of predicates ?? []) { + proofRequest.requested_predicates[utils.uuid()] = { + name: proposedPredicate.name, + p_type: proposedPredicate.predicate, + p_value: proposedPredicate.threshold, + restrictions: [ + { + cred_def_id: proposedPredicate.credentialDefinitionId, + }, + ], + } + } + + return proofRequest +} diff --git a/packages/anoncreds/src/utils/credential.ts b/packages/anoncreds/src/utils/credential.ts index 6310270980..eee27cccab 100644 --- a/packages/anoncreds/src/utils/credential.ts +++ b/packages/anoncreds/src/utils/credential.ts @@ -1,7 +1,7 @@ import type { AnonCredsSchema, AnonCredsCredentialValues } from '../models' import type { CredentialPreviewAttributeOptions, LinkedAttachment } from '@aries-framework/core' -import { CredentialPreviewAttribute, AriesFrameworkError, Hasher, encodeAttachment } from '@aries-framework/core' +import { AriesFrameworkError, Hasher, encodeAttachment } from '@aries-framework/core' import BigNumber from 'bn.js' const isString = (value: unknown): value is string => typeof value === 'string' @@ -34,7 +34,7 @@ export function convertAttributesToCredentialValues( return { [attribute.name]: { raw: attribute.value, - encoded: encode(attribute.value), + encoded: encodeCredentialValue(attribute.value), }, ...credentialValues, } @@ -109,8 +109,8 @@ export function assertCredentialValuesMatch( * @see https://github.com/hyperledger/aries-framework-dotnet/blob/a18bef91e5b9e4a1892818df7408e2383c642dfa/src/Hyperledger.Aries/Utils/CredentialUtils.cs#L78-L89 * @see https://github.com/hyperledger/aries-rfcs/blob/be4ad0a6fb2823bb1fc109364c96f077d5d8dffa/features/0037-present-proof/README.md#verifying-claims-of-indy-based-verifiable-credentials */ -export function checkValidEncoding(raw: unknown, encoded: string) { - return encoded === encode(raw) +export function checkValidCredentialValueEncoding(raw: unknown, encoded: string) { + return encoded === encodeCredentialValue(raw) } /** @@ -123,7 +123,7 @@ export function checkValidEncoding(raw: unknown, encoded: string) { * @see https://github.com/hyperledger/aries-rfcs/blob/be4ad0a6fb2823bb1fc109364c96f077d5d8dffa/features/0037-present-proof/README.md#verifying-claims-of-indy-based-verifiable-credentials * @see https://github.com/hyperledger/aries-rfcs/blob/be4ad0a6fb2823bb1fc109364c96f077d5d8dffa/features/0036-issue-credential/README.md#encoding-claims-for-indy-based-verifiable-credentials */ -export function encode(value: unknown) { +export function encodeCredentialValue(value: unknown) { const isEmpty = (value: unknown) => isString(value) && value === '' // If bool return bool as number string @@ -153,7 +153,7 @@ export function encode(value: unknown) { return new BigNumber(Hasher.hash(Buffer.from(value as string), 'sha2-256')).toString() } -export function assertAttributesMatch(schema: AnonCredsSchema, attributes: CredentialPreviewAttribute[]) { +export function assertAttributesMatch(schema: AnonCredsSchema, attributes: CredentialPreviewAttributeOptions[]) { const schemaAttributes = schema.attrNames const credAttributes = attributes.map((a) => a.name) @@ -178,7 +178,7 @@ export function assertAttributesMatch(schema: AnonCredsSchema, attributes: Crede * */ export function createAndLinkAttachmentsToPreview( attachments: LinkedAttachment[], - previewAttributes: CredentialPreviewAttribute[] + previewAttributes: CredentialPreviewAttributeOptions[] ) { const credentialPreviewAttributeNames = previewAttributes.map((attribute) => attribute.name) const newPreviewAttributes = [...previewAttributes] @@ -187,12 +187,11 @@ export function createAndLinkAttachmentsToPreview( if (credentialPreviewAttributeNames.includes(linkedAttachment.attributeName)) { throw new AriesFrameworkError(`linkedAttachment ${linkedAttachment.attributeName} already exists in the preview`) } else { - const credentialPreviewAttribute = new CredentialPreviewAttribute({ + newPreviewAttributes.push({ name: linkedAttachment.attributeName, mimeType: linkedAttachment.attachment.mimeType, value: encodeAttachment(linkedAttachment.attachment), }) - newPreviewAttributes.push(credentialPreviewAttribute) } }) diff --git a/packages/anoncreds/src/utils/hasDuplicateGroupNames.ts b/packages/anoncreds/src/utils/hasDuplicateGroupNames.ts new file mode 100644 index 0000000000..f4915fe6fc --- /dev/null +++ b/packages/anoncreds/src/utils/hasDuplicateGroupNames.ts @@ -0,0 +1,23 @@ +import type { AnonCredsProofRequest } from '../models' + +function attributeNamesToArray(proofRequest: AnonCredsProofRequest) { + // Attributes can contain either a `name` string value or an `names` string array. We reduce it to a single array + // containing all attribute names from the requested attributes. + return Object.values(proofRequest.requested_attributes).reduce( + (names, a) => [...names, ...(a.name ? [a.name] : a.names ? a.names : [])], + [] + ) +} + +function predicateNamesToArray(proofRequest: AnonCredsProofRequest) { + return Array.from(new Set(Object.values(proofRequest.requested_predicates).map((a) => a.name))) +} + +// TODO: This is still not ideal. The requested groups can specify different credentials using restrictions. +export function hasDuplicateGroupsNamesInProofRequest(proofRequest: AnonCredsProofRequest) { + const attributes = attributeNamesToArray(proofRequest) + const predicates = predicateNamesToArray(proofRequest) + + const duplicates = predicates.find((item) => attributes.indexOf(item) !== -1) + return duplicates !== undefined +} diff --git a/packages/anoncreds/src/utils/index.ts b/packages/anoncreds/src/utils/index.ts new file mode 100644 index 0000000000..a140e13cfb --- /dev/null +++ b/packages/anoncreds/src/utils/index.ts @@ -0,0 +1,8 @@ +export { createRequestFromPreview } from './createRequestFromPreview' +export { sortRequestedCredentialsMatches } from './sortRequestedCredentialsMatches' +export { hasDuplicateGroupsNamesInProofRequest } from './hasDuplicateGroupNames' +export { areAnonCredsProofRequestsEqual } from './areRequestsEqual' +export { downloadTailsFile } from './tails' +export { assertRevocationInterval } from './revocationInterval' +export { encodeCredentialValue, checkValidCredentialValueEncoding } from './credential' +export { IsMap } from './isMap' diff --git a/packages/anoncreds/src/utils/isMap.ts b/packages/anoncreds/src/utils/isMap.ts new file mode 100644 index 0000000000..1ee81fe4a4 --- /dev/null +++ b/packages/anoncreds/src/utils/isMap.ts @@ -0,0 +1,19 @@ +import type { ValidationOptions } from 'class-validator' + +import { ValidateBy, buildMessage } from 'class-validator' + +/** + * Checks if a given value is a Map + */ +export function IsMap(validationOptions?: ValidationOptions): PropertyDecorator { + return ValidateBy( + { + name: 'isMap', + validator: { + validate: (value: unknown): boolean => value instanceof Map, + defaultMessage: buildMessage((eachPrefix) => eachPrefix + '$property must be a Map', validationOptions), + }, + }, + validationOptions + ) +} diff --git a/packages/anoncreds/src/utils/revocationInterval.ts b/packages/anoncreds/src/utils/revocationInterval.ts new file mode 100644 index 0000000000..caf40b93c1 --- /dev/null +++ b/packages/anoncreds/src/utils/revocationInterval.ts @@ -0,0 +1,17 @@ +import type { AnonCredsNonRevokedInterval } from '../models' + +import { AriesFrameworkError } from '@aries-framework/core' + +// TODO: Add Test +// Check revocation interval in accordance with https://github.com/hyperledger/aries-rfcs/blob/main/concepts/0441-present-proof-best-practices/README.md#semantics-of-non-revocation-interval-endpoints +export function assertRevocationInterval(nonRevokedInterval: AnonCredsNonRevokedInterval) { + if (!nonRevokedInterval.to) { + throw new AriesFrameworkError(`Presentation requests proof of non-revocation with no 'to' value specified`) + } + + if ((nonRevokedInterval.from || nonRevokedInterval.from === 0) && nonRevokedInterval.to !== nonRevokedInterval.from) { + throw new AriesFrameworkError( + `Presentation requests proof of non-revocation with an interval from: '${nonRevokedInterval.from}' that does not match the interval to: '${nonRevokedInterval.to}', as specified in Aries RFC 0441` + ) + } +} diff --git a/packages/anoncreds/src/utils/sortRequestedCredentialsMatches.ts b/packages/anoncreds/src/utils/sortRequestedCredentialsMatches.ts new file mode 100644 index 0000000000..1d190c7e31 --- /dev/null +++ b/packages/anoncreds/src/utils/sortRequestedCredentialsMatches.ts @@ -0,0 +1,33 @@ +import type { AnonCredsRequestedAttributeMatch, AnonCredsRequestedPredicateMatch } from '../models' + +/** + * Sort requested attributes and predicates by `revoked` status. The order is: + * - first credentials with `revoked` set to undefined, this means no revocation status is needed for the credentials + * - then credentials with `revoked` set to false, this means the credentials are not revoked + * - then credentials with `revoked` set to true, this means the credentials are revoked + */ +export function sortRequestedCredentialsMatches< + Requested extends Array | Array +>(credentials: Requested) { + const staySame = 0 + const credentialGoUp = -1 + const credentialGoDown = 1 + + // Clone as sort is in place + const credentialsClone = [...credentials] + + return credentialsClone.sort((credential, compareTo) => { + // Nothing needs to happen if values are the same + if (credential.revoked === compareTo.revoked) return staySame + + // Undefined always is at the top + if (credential.revoked === undefined) return credentialGoUp + if (compareTo.revoked === undefined) return credentialGoDown + + // Then revoked + if (credential.revoked === false) return credentialGoUp + + // It means that compareTo is false and credential is true + return credentialGoDown + }) +} diff --git a/packages/anoncreds/src/utils/tails.ts b/packages/anoncreds/src/utils/tails.ts new file mode 100644 index 0000000000..9ae29aa8e4 --- /dev/null +++ b/packages/anoncreds/src/utils/tails.ts @@ -0,0 +1,57 @@ +import type { AgentContext, FileSystem } from '@aries-framework/core' + +import { TypedArrayEncoder, InjectionSymbols } from '@aries-framework/core' + +const getTailsFilePath = (basePath: string, tailsHash: string) => `${basePath}/afj/anoncreds/tails/${tailsHash}` + +export function tailsFileExists(agentContext: AgentContext, tailsHash: string): Promise { + const fileSystem = agentContext.dependencyManager.resolve(InjectionSymbols.FileSystem) + const tailsFilePath = getTailsFilePath(fileSystem.basePath, 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.basePath, 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/askar/src/utils/askarWalletConfig.ts b/packages/askar/src/utils/askarWalletConfig.ts index dcf1d15ab1..2337988f26 100644 --- a/packages/askar/src/utils/askarWalletConfig.ts +++ b/packages/askar/src/utils/askarWalletConfig.ts @@ -9,13 +9,13 @@ export const keyDerivationMethodToStoreKeyMethod = (keyDerivationMethod?: KeyDer return undefined } - const correspondanceTable = { + const correspondenceTable = { [KeyDerivationMethod.Raw]: StoreKeyMethod.Raw, [KeyDerivationMethod.Argon2IInt]: `${StoreKeyMethod.Kdf}:argon2i:int`, [KeyDerivationMethod.Argon2IMod]: `${StoreKeyMethod.Kdf}:argon2i:mod`, } - return correspondanceTable[keyDerivationMethod] as StoreKeyMethod + return correspondenceTable[keyDerivationMethod] as StoreKeyMethod } export const uriFromWalletConfig = (walletConfig: WalletConfig, basePath: string): { uri: string; path?: string } => { diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts index 91d22659c4..42cf5984b1 100644 --- a/packages/core/src/index.ts +++ b/packages/core/src/index.ts @@ -28,7 +28,7 @@ export type { WalletExportImportConfig, } from './types' export { DidCommMimeType, KeyDerivationMethod } from './types' -export type { FileSystem } from './storage/FileSystem' +export type { FileSystem, DownloadToFileOptions } from './storage/FileSystem' export * from './storage/BaseRecord' export { InMemoryMessageRepository } from './storage/InMemoryMessageRepository' export { Repository } from './storage/Repository' diff --git a/packages/core/src/modules/credentials/formats/CredentialFormatServiceOptions.ts b/packages/core/src/modules/credentials/formats/CredentialFormatServiceOptions.ts index 2d79961ebb..9e438c6d1c 100644 --- a/packages/core/src/modules/credentials/formats/CredentialFormatServiceOptions.ts +++ b/packages/core/src/modules/credentials/formats/CredentialFormatServiceOptions.ts @@ -2,7 +2,7 @@ import type { CredentialFormat, CredentialFormatPayload } from './CredentialForm import type { CredentialFormatService } from './CredentialFormatService' import type { Attachment } from '../../../decorators/attachment/Attachment' import type { CredentialFormatSpec } from '../models/CredentialFormatSpec' -import type { CredentialPreviewAttribute } from '../models/CredentialPreviewAttribute' +import type { CredentialPreviewAttributeOptions } from '../models/CredentialPreviewAttribute' import type { CredentialExchangeRecord } from '../repository/CredentialExchangeRecord' /** @@ -72,7 +72,7 @@ export interface CredentialFormatAcceptProposalOptions { @@ -90,7 +90,7 @@ export interface CredentialFormatAcceptOfferOptions } export interface CredentialFormatCreateOfferReturn extends CredentialFormatCreateReturn { - previewAttributes?: CredentialPreviewAttribute[] + previewAttributes?: CredentialPreviewAttributeOptions[] } export interface CredentialFormatCreateRequestOptions { diff --git a/packages/core/src/modules/credentials/models/CredentialPreviewAttribute.ts b/packages/core/src/modules/credentials/models/CredentialPreviewAttribute.ts index 89c3397b09..0f341785c4 100644 --- a/packages/core/src/modules/credentials/models/CredentialPreviewAttribute.ts +++ b/packages/core/src/modules/credentials/models/CredentialPreviewAttribute.ts @@ -35,5 +35,5 @@ export class CredentialPreviewAttribute { } export interface CredentialPreviewOptions { - attributes: CredentialPreviewAttribute[] + attributes: CredentialPreviewAttributeOptions[] } diff --git a/packages/core/src/modules/credentials/protocol/v1/V1CredentialProtocol.ts b/packages/core/src/modules/credentials/protocol/v1/V1CredentialProtocol.ts index 59338c7835..6ee07a7588 100644 --- a/packages/core/src/modules/credentials/protocol/v1/V1CredentialProtocol.ts +++ b/packages/core/src/modules/credentials/protocol/v1/V1CredentialProtocol.ts @@ -177,7 +177,7 @@ export class V1CredentialProtocol associatedRecordId: credentialRecord.id, }) - credentialRecord.credentialAttributes = previewAttributes + credentialRecord.credentialAttributes = credentialProposal?.attributes await credentialRepository.save(agentContext, credentialRecord) this.emitStateChangedEvent(agentContext, credentialRecord, null) @@ -336,7 +336,7 @@ export class V1CredentialProtocol message.setThread({ threadId: credentialRecord.threadId }) - credentialRecord.credentialAttributes = previewAttributes + credentialRecord.credentialAttributes = message.credentialPreview.attributes credentialRecord.autoAcceptCredential = autoAcceptCredential ?? credentialRecord.autoAcceptCredential await this.updateState(agentContext, credentialRecord, CredentialState.OfferSent) @@ -393,7 +393,7 @@ export class V1CredentialProtocol }) message.setThread({ threadId: credentialRecord.threadId }) - credentialRecord.credentialAttributes = previewAttributes + credentialRecord.credentialAttributes = message.credentialPreview.attributes credentialRecord.autoAcceptCredential = autoAcceptCredential ?? credentialRecord.autoAcceptCredential await this.updateState(agentContext, credentialRecord, CredentialState.OfferSent) @@ -472,7 +472,7 @@ export class V1CredentialProtocol role: DidCommMessageRole.Sender, }) - credentialRecord.credentialAttributes = previewAttributes + credentialRecord.credentialAttributes = message.credentialPreview.attributes await credentialRepository.save(agentContext, credentialRecord) this.emitStateChangedEvent(agentContext, credentialRecord, null) @@ -707,7 +707,7 @@ export class V1CredentialProtocol }) // Update record - credentialRecord.credentialAttributes = previewAttributes + credentialRecord.credentialAttributes = message.credentialPreview?.attributes credentialRecord.linkedAttachments = linkedAttachments?.map((attachment) => attachment.attachment) credentialRecord.autoAcceptCredential = autoAcceptCredential ?? credentialRecord.autoAcceptCredential await this.updateState(agentContext, credentialRecord, CredentialState.ProposalSent) diff --git a/packages/core/src/modules/credentials/protocol/v1/messages/V1CredentialPreview.ts b/packages/core/src/modules/credentials/protocol/v1/messages/V1CredentialPreview.ts index 9fe8aa5fc3..da44d37618 100644 --- a/packages/core/src/modules/credentials/protocol/v1/messages/V1CredentialPreview.ts +++ b/packages/core/src/modules/credentials/protocol/v1/messages/V1CredentialPreview.ts @@ -17,7 +17,7 @@ import { CredentialPreviewAttribute } from '../../../models/CredentialPreviewAtt export class V1CredentialPreview { public constructor(options: CredentialPreviewOptions) { if (options) { - this.attributes = options.attributes + this.attributes = options.attributes.map((a) => new CredentialPreviewAttribute(a)) } } diff --git a/packages/core/src/modules/credentials/protocol/v2/messages/V2CredentialPreview.ts b/packages/core/src/modules/credentials/protocol/v2/messages/V2CredentialPreview.ts index d566faa1a0..ea78448593 100644 --- a/packages/core/src/modules/credentials/protocol/v2/messages/V2CredentialPreview.ts +++ b/packages/core/src/modules/credentials/protocol/v2/messages/V2CredentialPreview.ts @@ -17,7 +17,7 @@ import { CredentialPreviewAttribute } from '../../../models/CredentialPreviewAtt export class V2CredentialPreview { public constructor(options: CredentialPreviewOptions) { if (options) { - this.attributes = options.attributes + this.attributes = options.attributes.map((a) => new CredentialPreviewAttribute(a)) } } diff --git a/packages/core/src/modules/proofs/models/index.ts b/packages/core/src/modules/proofs/models/index.ts index 9e20094e5e..9dec0e697a 100644 --- a/packages/core/src/modules/proofs/models/index.ts +++ b/packages/core/src/modules/proofs/models/index.ts @@ -1,2 +1,3 @@ export * from './ProofAutoAcceptType' export * from './ProofState' +export * from './ProofFormatSpec' diff --git a/packages/core/src/storage/FileSystem.ts b/packages/core/src/storage/FileSystem.ts index b724e68158..c5996e78b2 100644 --- a/packages/core/src/storage/FileSystem.ts +++ b/packages/core/src/storage/FileSystem.ts @@ -1,3 +1,9 @@ +import type { Buffer } from '../utils/buffer' + +export interface DownloadToFileOptions { + verifyHash?: { algorithm: 'sha256'; hash: Buffer } +} + export interface FileSystem { readonly basePath: string @@ -5,5 +11,5 @@ export interface FileSystem { createDirectory(path: string): Promise write(path: string, data: string): Promise read(path: string): Promise - downloadToFile(url: string, path: string): Promise + downloadToFile(url: string, path: string, options?: DownloadToFileOptions): Promise } diff --git a/packages/indy-sdk/src/anoncreds/services/IndySdkHolderService.ts b/packages/indy-sdk/src/anoncreds/services/IndySdkHolderService.ts index 2e6e63ccc0..d5e82deea7 100644 --- a/packages/indy-sdk/src/anoncreds/services/IndySdkHolderService.ts +++ b/packages/indy-sdk/src/anoncreds/services/IndySdkHolderService.ts @@ -9,7 +9,7 @@ import type { StoreCredentialOptions, GetCredentialsForProofRequestOptions, GetCredentialsForProofRequestReturn, - AnonCredsRequestedCredentials, + AnonCredsSelectedCredentials, AnonCredsCredentialRequestMetadata, CreateLinkSecretOptions, CreateLinkSecretReturn, @@ -76,7 +76,7 @@ export class IndySdkHolderService implements AnonCredsHolderService { } public async createProof(agentContext: AgentContext, options: CreateProofOptions): Promise { - const { credentialDefinitions, proofRequest, requestedCredentials, schemas } = options + const { credentialDefinitions, proofRequest, selectedCredentials, schemas } = options assertIndySdkWallet(agentContext.wallet) @@ -85,7 +85,7 @@ export class IndySdkHolderService implements AnonCredsHolderService { const indyRevocationStates: RevStates = await this.indyRevocationService.createRevocationState( agentContext, proofRequest, - requestedCredentials, + selectedCredentials, options.revocationRegistries ) @@ -117,7 +117,7 @@ export class IndySdkHolderService implements AnonCredsHolderService { const indyProof = await this.indySdk.proverCreateProof( agentContext.wallet.handle, proofRequest as IndyProofRequest, - this.parseRequestedCredentials(requestedCredentials), + this.parseSelectedCredentials(selectedCredentials), agentContext.wallet.masterSecretId, indySchemas, indyCredentialDefinitions, @@ -133,7 +133,7 @@ export class IndySdkHolderService implements AnonCredsHolderService { agentContext.config.logger.error(`Error creating Indy Proof`, { error, proofRequest, - requestedCredentials, + selectedCredentials, }) throw isIndyError(error) ? new IndySdkError(error) : error @@ -338,27 +338,27 @@ export class IndySdkHolderService implements AnonCredsHolderService { } /** - * Converts a public api form of {@link RequestedCredentials} interface into a format {@link Indy.IndyRequestedCredentials} that Indy SDK expects. + * Converts a public api form of {@link AnonCredsSelectedCredentials} interface into a format {@link Indy.IndyRequestedCredentials} that Indy SDK expects. **/ - private parseRequestedCredentials(requestedCredentials: AnonCredsRequestedCredentials): IndyRequestedCredentials { + private parseSelectedCredentials(selectedCredentials: AnonCredsSelectedCredentials): IndyRequestedCredentials { const indyRequestedCredentials: IndyRequestedCredentials = { requested_attributes: {}, requested_predicates: {}, self_attested_attributes: {}, } - for (const groupName in requestedCredentials.requestedAttributes) { + for (const groupName in selectedCredentials.attributes) { indyRequestedCredentials.requested_attributes[groupName] = { - cred_id: requestedCredentials.requestedAttributes[groupName].credentialId, - revealed: requestedCredentials.requestedAttributes[groupName].revealed, - timestamp: requestedCredentials.requestedAttributes[groupName].timestamp, + cred_id: selectedCredentials.attributes[groupName].credentialId, + revealed: selectedCredentials.attributes[groupName].revealed, + timestamp: selectedCredentials.attributes[groupName].timestamp, } } - for (const groupName in requestedCredentials.requestedPredicates) { + for (const groupName in selectedCredentials.predicates) { indyRequestedCredentials.requested_predicates[groupName] = { - cred_id: requestedCredentials.requestedPredicates[groupName].credentialId, - timestamp: requestedCredentials.requestedPredicates[groupName].timestamp, + cred_id: selectedCredentials.predicates[groupName].credentialId, + timestamp: selectedCredentials.predicates[groupName].timestamp, } } diff --git a/packages/indy-sdk/src/anoncreds/services/IndySdkRevocationService.ts b/packages/indy-sdk/src/anoncreds/services/IndySdkRevocationService.ts index 30f78bcbff..ed2572dee7 100644 --- a/packages/indy-sdk/src/anoncreds/services/IndySdkRevocationService.ts +++ b/packages/indy-sdk/src/anoncreds/services/IndySdkRevocationService.ts @@ -2,7 +2,7 @@ import type { AnonCredsRevocationRegistryDefinition, AnonCredsRevocationStatusList, AnonCredsProofRequest, - AnonCredsRequestedCredentials, + AnonCredsSelectedCredentials, AnonCredsCredentialInfo, AnonCredsNonRevokedInterval, } from '@aries-framework/anoncreds' @@ -44,7 +44,7 @@ export class IndySdkRevocationService { public async createRevocationState( agentContext: AgentContext, proofRequest: AnonCredsProofRequest, - requestedCredentials: AnonCredsRequestedCredentials, + selectedCredentials: AnonCredsSelectedCredentials, revocationRegistries: { [revocationRegistryDefinitionId: string]: { // Tails is already downloaded @@ -59,7 +59,7 @@ export class IndySdkRevocationService { try { agentContext.config.logger.debug(`Creating Revocation State(s) for proof request`, { proofRequest, - requestedCredentials, + selectedCredentials, }) const indyRevocationStates: RevStates = {} const referentCredentials: Array<{ @@ -70,18 +70,18 @@ export class IndySdkRevocationService { }> = [] //Retrieve information for referents and push to single array - for (const [referent, requestedCredential] of Object.entries(requestedCredentials.requestedAttributes ?? {})) { + for (const [referent, selectedCredential] of Object.entries(selectedCredentials.attributes ?? {})) { referentCredentials.push({ referent, - credentialInfo: requestedCredential.credentialInfo, + credentialInfo: selectedCredential.credentialInfo, type: RequestReferentType.Attribute, referentRevocationInterval: proofRequest.requested_attributes[referent].non_revoked, }) } - for (const [referent, requestedCredential] of Object.entries(requestedCredentials.requestedPredicates ?? {})) { + for (const [referent, selectedCredential] of Object.entries(selectedCredentials.predicates ?? {})) { referentCredentials.push({ referent, - credentialInfo: requestedCredential.credentialInfo, + credentialInfo: selectedCredential.credentialInfo, type: RequestReferentType.Predicate, referentRevocationInterval: proofRequest.requested_predicates[referent].non_revoked, }) @@ -138,7 +138,7 @@ export class IndySdkRevocationService { agentContext.config.logger.error(`Error creating Indy Revocation State for Proof Request`, { error, proofRequest, - requestedCredentials, + selectedCredentials, }) throw isIndyError(error) ? new IndySdkError(error) : error diff --git a/packages/indy-sdk/src/anoncreds/services/IndySdkVerifierService.ts b/packages/indy-sdk/src/anoncreds/services/IndySdkVerifierService.ts index 3e76fc6bc9..e4e4cb1d2d 100644 --- a/packages/indy-sdk/src/anoncreds/services/IndySdkVerifierService.ts +++ b/packages/indy-sdk/src/anoncreds/services/IndySdkVerifierService.ts @@ -1,5 +1,6 @@ import type { AnonCredsVerifierService, VerifyProofOptions } from '@aries-framework/anoncreds' -import type { CredentialDefs, Schemas, RevocRegDefs, RevRegs, IndyProofRequest } from 'indy-sdk' +import type { AgentContext } from '@aries-framework/core' +import type { CredentialDefs, Schemas, RevocRegDefs, RevRegs, IndyProofRequest, IndyProof } from 'indy-sdk' import { inject, injectable } from '@aries-framework/core' @@ -21,7 +22,7 @@ export class IndySdkVerifierService implements AnonCredsVerifierService { this.indySdk = indySdk } - public async verifyProof(options: VerifyProofOptions): Promise { + public async verifyProof(agentContext: AgentContext, options: VerifyProofOptions): Promise { try { // The AnonCredsSchema doesn't contain the seqNo anymore. However, the indy credential definition id // does contain the seqNo, so we can extract it from the credential definition id. @@ -53,8 +54,8 @@ export class IndySdkVerifierService implements AnonCredsVerifierService { const indyRevocationDefinitions: RevocRegDefs = {} const indyRevocationRegistries: RevRegs = {} - for (const revocationRegistryDefinitionId in options.revocationStates) { - const { definition, revocationStatusLists } = options.revocationStates[revocationRegistryDefinitionId] + for (const revocationRegistryDefinitionId in options.revocationRegistries) { + const { definition, revocationStatusLists } = options.revocationRegistries[revocationRegistryDefinitionId] indyRevocationDefinitions[revocationRegistryDefinitionId] = indySdkRevocationRegistryDefinitionFromAnonCreds( revocationRegistryDefinitionId, definition @@ -74,7 +75,7 @@ export class IndySdkVerifierService implements AnonCredsVerifierService { return await this.indySdk.verifierVerifyProof( options.proofRequest as IndyProofRequest, - options.proof, + options.proof as IndyProof, indySchemas, indyCredentialDefinitions, indyRevocationDefinitions, diff --git a/packages/node/package.json b/packages/node/package.json index 665472421c..30ffadd1f6 100644 --- a/packages/node/package.json +++ b/packages/node/package.json @@ -43,6 +43,7 @@ "@types/node-fetch": "^2.5.10", "@types/ref-napi": "^3.0.4", "@types/ws": "^7.4.6", + "nock": "^13.3.0", "rimraf": "^4.0.7", "typescript": "~4.9.4" } diff --git a/packages/node/src/NodeFileSystem.ts b/packages/node/src/NodeFileSystem.ts index 240440d64c..a5caf0d070 100644 --- a/packages/node/src/NodeFileSystem.ts +++ b/packages/node/src/NodeFileSystem.ts @@ -1,5 +1,7 @@ -import type { FileSystem } from '@aries-framework/core' +import type { DownloadToFileOptions, FileSystem } from '@aries-framework/core' +import { AriesFrameworkError, TypedArrayEncoder } from '@aries-framework/core' +import { createHash } from 'crypto' import fs, { promises } from 'fs' import http from 'http' import https from 'https' @@ -44,13 +46,14 @@ export class NodeFileSystem implements FileSystem { return readFile(path, { encoding: 'utf-8' }) } - public async downloadToFile(url: string, path: string) { + public async downloadToFile(url: string, path: string, options: DownloadToFileOptions) { const httpMethod = url.startsWith('https') ? https : http // Make sure parent directories exist await promises.mkdir(dirname(path), { recursive: true }) const file = fs.createWriteStream(path) + const hash = options.verifyHash ? createHash('sha256') : undefined return new Promise((resolve, reject) => { httpMethod @@ -60,9 +63,26 @@ export class NodeFileSystem implements FileSystem { reject(`Unable to download file from url: ${url}. Response status was ${response.statusCode}`) } + hash && response.pipe(hash) response.pipe(file) - file.on('finish', () => { + file.on('finish', async () => { file.close() + + if (hash && options.verifyHash?.hash) { + hash.end() + const digest = hash.digest() + if (digest.compare(options.verifyHash.hash) !== 0) { + await fs.promises.unlink(path) + + reject( + new AriesFrameworkError( + `Hash of downloaded file does not match expected hash. Expected: ${ + options.verifyHash.hash + }, Actual: ${TypedArrayEncoder.toUtf8String(digest)})}` + ) + ) + } + } resolve() }) }) diff --git a/packages/node/tests/NodeFileSystem.test.ts b/packages/node/tests/NodeFileSystem.test.ts index e242b43cdd..f031ee32e5 100644 --- a/packages/node/tests/NodeFileSystem.test.ts +++ b/packages/node/tests/NodeFileSystem.test.ts @@ -1,13 +1,42 @@ +import { TypedArrayEncoder } from '@aries-framework/core' +import nock, { cleanAll, enableNetConnect } from 'nock' +import path from 'path' + import { NodeFileSystem } from '../src/NodeFileSystem' describe('@aries-framework/file-system-node', () => { describe('NodeFileSystem', () => { const fileSystem = new NodeFileSystem() + afterAll(() => { + cleanAll() + enableNetConnect() + }) + describe('exists()', () => { it('should return false if the pash does not exist', () => { return expect(fileSystem.exists('some-random-path')).resolves.toBe(false) }) }) + + describe('downloadToFile()', () => { + test('should verify the hash', async () => { + // Mock tails file + nock('https://tails.prod.absa.africa') + .get('/api/public/tails/4B1NxYuGxwYMe5BAyP9NXkUmbEkDATo4oGZCgjXQ3y1p') + .replyWithFile(200, path.join(__dirname, '__fixtures__/tailsFile')) + + await fileSystem.downloadToFile( + 'https://tails.prod.absa.africa/api/public/tails/4B1NxYuGxwYMe5BAyP9NXkUmbEkDATo4oGZCgjXQ3y1p', + `${fileSystem.basePath}/afj/tails/4B1NxYuGxwYMe5BAyP9NXkUmbEkDATo4oGZCgjXQ3y1p`, + { + verifyHash: { + algorithm: 'sha256', + hash: TypedArrayEncoder.fromBase58('4B1NxYuGxwYMe5BAyP9NXkUmbEkDATo4oGZCgjXQ3y1p'), + }, + } + ) + }) + }) }) }) diff --git a/packages/node/tests/__fixtures__/tailsFile b/packages/node/tests/__fixtures__/tailsFile new file mode 100644 index 0000000000000000000000000000000000000000..73f04718605544e6cbb4c49181306e356efa12c3 GIT binary patch literal 65666 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

*Lp9z-c8{EH>-8`$ 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�$!oG8BzG=voweIET<9d&h?-(VM{i(DT;h z@jAKcy8H3l{h3}UwhgfQr5&DT)}(a2SUkRh5AqIy1g|ans6n&| zN|%^h^x@Ix#OIM1{+99`?En@ebUvcyU>$9x&BzG}H{^XeIMZ;y;NKBLFdit>7tZsf zv`6+fjmo3`BPSLcG;t7ck$*dF0qc|6W$)nu)h_f9mRkEH)mP1q~IqHcLqW2W_;cE~DaiHryztXKFU8IIv!@wHDRvD$gr8w7{QOipA}6Y2AZ05fz0u4`j)lV3K1}`4 zXn=dmA~2s&9^d!`)K=D0ii561(!MASJNz`H=kLGs;wyp7m5TX(;24z&_`_{lsH@-^ zuZJFHRf=KC3b|ZlS8r*4rI*IJd{e6sx*Z(*kJuAZM5cGsvC ze|e7^0oUaoL^8Yem*__)GTxrY6_Ije^9^ziE-J+2itSFF(JS4iaDDAGa!>6RzJ&97 z>D(>tIeZEMVYv{d;;PR3NP3=scDz0r2h|oBdGVbXDk~P-$CdR5yAtS@1~3WQ;M#fj z{g9W^Te5pfQO)m&q=`126t8ylP@gsWJeveY05n?-H{Jz^x@g< zz2I*n28dJeTh7oRS1j5A!%uUz2o=EkW6m`fxil%L=I`fws}U_nVYF!;xmpFN(wcah z>tgWdG9bdtR^ck0X*;DBc+xw_sgM03AVtVA@9Uz9nj;TBJw<%x`1n>w=xsz0$A6!W zUv#z+tL6DA7=DrmZ8);D!t5V2vnnr?6=jN}b6v*#V^uN}+3&}jLh>1KUxd0IuVS6H zMlI%Nx?S_^yVjqGODH}gxn*9KI&32XD_sPZ#L0Y{^8%5BCJ)ikLCmpkJ_4K@*~qjE z;^VN^C4GQ+krpB!bJ*mpWRieSdaE5|hS zq`MHM!s1H{#&iM5WnA>8Do?nVt;0S5f~xi)e7kC9*f?_>P)VW}9M^4}t`z+PI1}CB z%lzC>wWH16tyyCnEA0Qi?otvX3~EDLW&BX_;O~U%Jrzx{(f(A6#U$X`e9CKzrx@xQ zXmV6Hxe9UtC<&->5pO&nVo|>D=~AeB@;IW59d4}@WWq*B$s)sfdSdaobMUcLKz`yT zX8N<=s+Y(n$ltpNm>tE@{JX<*E6+$dSY>R+=_l2V_wJ=yoeJTLdaLh8k^#RyhF zD7oK~?Rrnrj8hz(GB)uDE0A|TQYje&O4#jN>4UX(i&(JwOupf=I{eI@oegE#HV8!seSj9FcUps9gK|B(ddB{k6!sk|x>EQi z0*&b=1!ZCY+Rw3DF-)&9QentX5bq6co~cNa!1%7_v_;j2=W?@u> z4Co+h#v1Vvl(9d+{EBz#Q(XiP$7jq=*PFXZnxh@~pxUWn!MWq>5q(WIF7y2W0fUnX zOE6sa6;e*$fh6Pz-+)Chp~;umCo)*B`Z#TCK%%7z#si`2L73XUE}wbsX}i)EIdWIy z-HilPdCmjK9EO_>-8@UN4G)W}OyFJOp3!wrvcNxS!#7OvstNX^m3NK=3D-dn84Sv2 zOtri0_WTJrgm-Dw(biOCAsCiU^Jg0&<;pMeALud3H6^M^@RcAvd+I1oh1-G!iraCW z;T^~XgQBx?@C|G?Z9LpsOageYq1Hra9|OlVC0E8F2?9$2go^c1k)+4M3CJm0+rS6+ ztu_&P;XFaJiR&Te<_vfl0)feZ0x%p)#M>I}AZ$#L{c{7{@~f&-t=aa-1B62oQh092 zQg#m&^*hpXdiT?B>KJ_rHSH3$n+p&xf)vUc9G6}=T5sy;rpRs*_#P6klTC9Zqx{Xp&NB}Sb+sh(`?KX(@rIUT# zD>4PB9IijuzM0PnqY{$ojJO*elp0xjyN8T5OiMteDA=#gqRiz$IzZ8&>J>=AZy_`q zn6wqwcV2TFGV<@XCuwg3`Op^u=PbTt>f{tf!oK|@JxdCWhKT)6>&@^9$`DoWWSO=H zxX|RRT%jEXZT71KA&B%{??9DJ;Nwj{`!zZC;!KAyTggnXNI5HEu_E^XY5*PzQ{CxZ z8T;3ibbb_bb5yCH=;x#oM%}DiSMaJCrCvwTuODK*X?e`T!y)l~a?leqJMwO9>8`lh zSs1qixFs5=L6{W;vSs~+XB)#eY2>8IcU>TdnCfA4ls1DJ3DpJFdvJFEg=W|==jw8Q zei-cw@4zFgcc{670GnVGcY6Jn)Wn_JVe-Z#3$sH(L5vl~nQA8AZ({D~_zx}|D^=6E z2XIz~aZl;n#%Ce(PG#FYGzrN=f#BiGOl=Mp?j&U(IS!YOcsAgpZnz*s+BIVnYH0HC ztT98i*L2ws4X!9YgKLdw%Qnl8m1hgcvx6o(?g9)zk6C*Fiw_3|Fsf#T45k&9W}&_$ zo`UAv5#VQj!-LQB737~sj71y?Phm!Y7?rg)F!Ej>BwRTi%+|v9BDZE;9N>tsh-Tm) zgG1Eh_TpGSA@xUX`G53H`HfTu;T0;9+1nm+5eG|fDs!p{MhOKft z=1)YLDIy59q6u&c11NU<-`$|vySllV9GxDg??Sy^M_s08DOfnT>pPinmHBrzf|Lw(h;xLkd6|_wUR33?YqSwwc?(rao)lVy9+ud&?WF zX<_RoWcJb;VfbfJurXV@oHk9YDtQ&0160U4_>!C%#G+B&pc%jrh49NMTo{w!*VwWo zTw4C+455+^p%!V(*JUESOR^*&U$TjL=oQu92h=?Gt09-b%c2Ux4;cyES=%(Uu+07j zTKQMj*z!dgWcMMtl1KosOUxO*=ccuqwXF)#tm%UU)^PpB_wVmfsqH51FI-gtG}T!* zyPyD!H$RF%PA{kw^7M)W2+=1k?eeLaWZvj7uk-``xcBG>YiJ4V!sGyJ>Y0j-1dc%8)@1 zaj;-g-gmpt>!?F`QJ?b()#C?k2ZN=dtVZIo2h_5@n^b4VmO}nV5HHU|!B9U2Q{BSf3hz zT{NS;lNGlSXpP3SG}Q}tw+c$1k|cm0gU1)gUI&w-Z$i62Il`(PD@QRdtuE>_n}}AB zy94BI9p6#wE0{Y(;0|G0cY4DDae96$l_-K3M;!Vh#K#D4*RaA}1Ne9CIc66_ev*t1 z4ZVMyR`5i<#Ek9OdMtiZ8?<6UU@uERvrs-fKJZZ#$1)Tk0+gmls)ye9Vp^w#H?oxD zw17td)+KMjMi$lp-_VL%t~roMhN|H(nve6dUL;s{`)x+eO7=RrVWb)a(Egpy-uZmP zC;4dmznd(&T6!&ZmZEEHR2i8?@5gQ;?r<5M-{o#`rG|~$D0~r4tGf*5Q%&wYx={q) zw?Cc_AixZ~dOy)Tf8KHY5VHow5f&g#q?sfn;Q_@I&Rn8jZQLe+gW8_ z>9r2SaFjWpQBiJA(LvW25~ju=0!uwXZfdgtRJpQV*iau1huGY$G|J!CCIu1*@BR`X z5slWxl_8c7A|N}@wq~V=l_k9+$Z;EoWjK`v-dF+?v}^gCwCK-{%FH_G|^m@OZ>-cX+71>+W%g1GejR9PA~OQf{cA1$4bh*1O*D^GMRT1(y{ zd$aN8mm2Zfr+~Cxp&1mC2dXLp3wPxDq$0IwrWKS8OtYZV1EMMamxjzIi7EL^wz!AG zgP5F#*u7y3psRymYeM;CIA z%QUgD%4nQjNv!vrLOj_Cb7Ih=w(0*Fn-ZZ8WI>lPSox;fA)lGPS7ct5abGwDj6PZJ zxHD4y9-zvVEga4MG~Rr23mnI$%gU#u(2kG<&V?dGVytz_-LYQi5UV(lP!?0jV}fT@ zZT6R7-izV^$L+yPH+tQw`HlzcZGX*n#y2IlyVlS%MP7Pq^Jl3W!c;iSl2jgM+=Vc^ zQNK&K84u&b4DJLf)v7(Nm*%XG6 z?ZI#i+q@}09cg(7e1_*{M#Sn4O{bIFoqEZ4<*+9EsrLO7Cq*7;0o(@LSn~Ss(m4tW z!p`;tZkZTRkpM3dkBVIboVAakom0`CAxN<|P+*_p~K$vI@j95eeZ40Zs-A!x%lHgBu^h*7ggseO$Ce$uR#6LDfaW ztxB{s4AWo^V{D^Q*e3~Q?OCAd_Z5ZpVPp{-CV+CR9ze5gIZ~t`j4xBmkrg_(A*45- zp9#xBH_sJ&%CbS0HFNg)(|N@mRG971*7;HcPHQn>x=I+pPx zP5Z4Wj}h=~;1;gc85I%bp5(0KJ&DV{EX;Ekc(%wG1cM&lPduWb(O3!A_dq4P;1h!p ztMAkNcPkrjFudCtvWcHxnK25IB8Mwd*ZXHmDu-h20VhF>3XN7b8N`@7k!x(XB-7ypRyO0 zX)FpAYqPEGtdNA}ycBo9RhSU|4d>OoQClqeX9?U>2};Y(hNHB=@2EL zjR}3f1Fo|PlwIp5yqPAFx->hnO1Z>PO}zUX9$uHd+I&1&7eBNU-O!RzYtA!Lt#kcS z+pc3(R#^ei1s`dQ5<`e8mTp%GbCMQXuREwVKI_qo-R`Vka;Mj!r3icF#SK%Nv(-8omfqQrV}m z-mhdZ>D`G}y!*rm#b(bTbKpvM7G`8TJ(qhYr{|AjAmD6nFV=`@6F1`np}N2)S87-@ zLT1d!=|Fpa^aaMSEm7_!prZyz!zVT#n<$#0lzDd1^SnJ^=C+i0oL%q904^N0I{7fm z(I0LQe5)5?V!z{3x_YUJ)TwBAA2CPe^^S`5pF8dYG6V${_F>QRgaySCXPQ_5zGNkZ zhO-#Jolxmea;NU11C2ij)Fb%dr~hY#D#Lw+tvD?0P>asRG)9Jf7%R~+%I4b_?9T~E z7;ye|6sy(PXp56+*bsjY3k@MNW8FNm+iU24)*{S@I69&`m^mf ztxi!yFzT=!-?!HsqRq&OP*Kb?Op+n$Cy8g7OBAmt*YgiJ918p)^TL&^1dIT>hEcyY z*7CWSc2Jsu%+FxKz5hgdC-&$LJu2%Zj@oHm_6WZN7P;O!>V36`#T|yj5^|`S|MP1a zcFBvn?{scQ;~N4{l)>y3ns8=z^%L91K79({v8c`nXf|?ORIei=*)svmW>4z5WO`bP zTTyo7G7sOZYRv=!&JEub|VcIcVK)x!(%P( zO{(2ZL_WeIhd4Gn_l9`g0X!$h%ROfxhmBQ9B>Bd@EGxvVy~;io`;OV}Ct`T$`VLPN zpxo6GhWy*ng{CSMdWnD#1fl8F)$APycU;ZdHeY&{=|v45HONXqRSQmtr~IUCISVo6 zhtnY?u=%IS+W^Qw3Nj!De7c6ur&1LBq$g%al5A$-`6YC6d>#`^Lr(D{v2wNmfF#_2 zv8OkF$}z_?gpbuxJWRxDmA ztVz7`IT)-EmM-L-hIF0ywNK{;0Oc+&_J$`rIX;vO~jmN~DRIhEje6zJVema;`^PoZ+)e zde-$V8_^m4=I-soc@0HU8$}Tib#@dZQR?Vm4ud?K^G)20-bXNALUBsdCALkg@@M2I zX8W58T!?~SGFrj4J{bdtmJR{&KMuf1X(l+u<;T*Emf#}?G=B9;9`*g?!&me2Y{|iE zTLqwUyl8`UnFEY|2gEaMeHT;+9iD3ZKfI{ov|p09weCH7{plf+bHjBCVmG}RO|1rt z5!dah%;K*wFuAb)mNkO?;hHy;&rQY0i731UIeXHF9~mnO#*((_)Kllx6h4TEHoSl8 zVg~Sq;_*sFA0+Vl4BHm&3ojKk@6r`RbBb*j4obd6s{EK!3Y(r#>0jVQQlPGY9X5)b9-QgynfdNTJ!nXFkNK4 z63d>$90%|sZnx?1u0(W{6ACGd&z1K40VyJ0ME>x3C{FrP5D-sgnhaE{IlF^`Y_U4b zqWgB6T^=gf)zdYYLMb$;7=+JT5*CY@qv)!ikZ;%LL)&yMcVcE2pyG=@ZoKA&2LhV7 z2@3E3|C}3F7_TSP9lDN%+hNg+mOM*LF+5)<6MoOW8CTe}+^f)+!rA@NSfYjACB1F~ zO)Bivq6BX$8;6$ZHu6nYF;1%vv+LO%QM_+vt02%8p3-ul{mEr=2Mhi$g&emg`#LeC z@Z4%=TpgeO>;g}YWLH&5$pVo+5pOzQrsy?g^mMLw$XgzM7Bm7i5VnZf;CSP~f*g(^ z7Xqkim|@YhE%WGV2%X%bRvg3z(7sv|U2z~gI=eT!?jEqL^}JbQ zDx23<1*LwJ+PoHK^kf@4mB&BdNh0|QLAcAsJSgo@bO7t|0%HA8o7-udj+~uwooN~h ze;X9?N?hGtaRiQ4>-laI6t=Jr%p=&r-TpQ{bT^czYz$_+(&lm!@9F;Py-_cO4t76c4oRm7(VhvnrnhFMQ8po$VQM$f9}CT%0%N1K z9c#>PgNb;T%HE19`iMZy{*5^!ZLSwx@C4W%*-04e9PZ$ch+v-^ZoM-Bc?{kENw4dQ zw>|X8S4TqLRdW!I5wk<3ICY{cG za+@l|4+&h)Zl_pjHJQ~!GMcFBzuLi-2P3Gf)6QMBkCG)D09STLEfUDJeop*2tuGa@ z7B_&hZoN$g&cu-L&BqAg5|_&$nyrZLstoWPD-)DS99OcGMwNjA#SEglw}?kN0g|}n zp8IcIA|BIe(H^(D$zu|rR2as;wi^lK^g`y84#7F1a5gXsFaMMy@r@?ib1jLVmLsnz zRia>=zp6YO5Kf}KCryH+@GRf2gm4w4L0W-$+KI#d(Iqj9OvNda+I02lf;Nygdk7 zyxA8%?Ha1Wik@AaE2qai@7uyH{)jj35UJ;whrafG9K9*d%m5_XP1b1JREu61i3wg) zP0A`48ZARM%R9?c1aP)_TiPg|BONIZ_BN!_A{fQiR}JHf3_ohF2AK8weX|5T+qJ0W zPj)Ib0-(OvyF?X+GcQ|D9&?K6+eOR0Km^09((YM{s58x_(d)sJrU1zeJ(BTU7DC1( zXppZ#RVQ^D6|mn^W24(;`cb8F;on*GX@5WX2<1ek2f6Qd*4|RF`evX=azoYMOtdS{ z73SXzrIf?5120e0yMbZj%Q`juH&FMnRn|u;DR%SWTeCfhy90*N5^j!7n??+Oz;u;aJ-j*I>d;-1BU8FWTR{0urxZM|8D*y6g}`E-B9iDoN2m zb&DY`s-?-!tuj_v2n4I#;YIyjPAsLqoC!<2`CC4T!M_j}*M~s!h^%IJ3Ef!=2^wlN z{Z+$!5#*eR&y}4PxK{MNwQ(Y|;o5U?B^vld&pnF!!H-1;zDWyqvLSQ{Y-Im6$VyO% zcUL(;7x>FG)1{5?97qBMm3S1j>hV~J&q&KV`LRIXqnY))8VsPL?A>U-d>`l(VJx9U z;RBZ^86^;=;cv<|cUG%F6jq9S*JC6$Vo>|WIBgjWvnQn9E?TdC2bz&p8t!!d5KP)8 zitq>8whV59V!p!#7j}?_8^IW5CZbV(xE-fG5XdxSQ`cBHnuVPm22H1|M3x-iJLT^0N1JS2jIY~uysgUh# zZt<)63fi1DUzBIB*;r{N(1#i_bJ9p1WF}GUY?PCd+y-A46*J65I1%6w0$>E3E9nuB z!qjAyFWVwYhuD8=Cr%R5BCIj@hHi!wnGJ{4U1u90M4*FkBb7n$D+1{|$(Z z&elYjeHDy(gPHq?hLco88bGLeuRI!wLc}M1&@3RLPt>u!q*0-FBDL_$61fgj9HV4u zNFCGvLQ8yRx)q8wYIbTd^UBD}Y{!d0pCl(E5tv=Cwv;&qicrv2{lT_zQ90arrp+Blb_4y>j=-Y-;rq+YydY{XhLH>4JsDuKGsI;);`W= zG2B-A2B;&?gio@KP=z$hdks3G1PYry;lP)pgz_XU$ryKuOWr3M-;TWkfJWVaoI(YU z9DI$Ry9prb{j%fR6cd=kQL|sND+5Kd_ICyFJ;9Lv4AQ2zz=o9-!w#r1>Ivd81U&U$ zX&1ozAIz5S9)(?g7m5{RU4$Kxh0^@_r=Zj;8NXSGy9N-34mnM zn_`sYq~5;WdaF=9VhoKG1j9DrCxocQyYp%Tmm#JwGEw{mnFA?_-f{c;kVJRW0P07Z zMhdJZ_gPO;JH3K46;4&8+q-(P;hs*>eoV z5r6t89Yofnv@}bN(GK2ng}42Z-lqJkp@*fLJexUl=+Y z0jF}^!~-zHo*)EIsD~zW4fE5`>5!m=e89vNr$H$CBj_&G3<~c31eu!jmdCypIbtJ= zY_Z&$;;UqW#a8-q3o?hh<0>Py{|$ts_?k~RKu$-U1X3H43e#Xxs>-7z4B^J%V9WN^ zl?Z87iRME=C@7zZpoGRDBq|BYjJ?*+A&g*a1<;Fv&iN* zob+aQ5f0|D-tpB1PnA_+M)}!i$oY7ebQ2mxMic~T+jF3I9J}H22OBo>qCt{%=$PH7 zv~VHqQZ$H7hHs`Ffbhi&5xMC0C%VBoW&`&WtbS;6R%LRNY?UJ>TL^b-KgprC1$YD; zPbGE~?Q@9r&2f$V`t)^J&ac4_i)X)BEr%s@5i>roOz!1N9vuHt5c7x01JQ0n**uyr z=qon0&RxjC;d z4Bep`slCltzd;&)SRYdJX?X>H?TN|gnJdj8E~F}50N74>yEP}5aBy_zoI|;Cj7F%m zNfqSLlNiuRh7DwX2*ghON72uL$nY9u?P?4jb_4}Xa_+1^d3dJovbZYOAODaWQp<- zLoo94xTh105t_=7vzsb-h#1TG#XBCmf%(b|P(@0iTvr5lv;I2|Bjtx*p2MaXrRJX2qgGp1NbTp*oY14Vdd(o%e^!2sla z1J0xTQViR7!shBboFC$xbt*g5qrec!^~aL`V2U&gA2*8tqewEjtKTQ?EK}hcn zQ`h0GYvPCICC@`S1R2LLJ@bd*9Q2hR3K{(Z^K7hCTx76~-x`-&Y}R!EcCr=k(nH0* z*-P3*4&-X5Qb!Cj;^V||@r<^Ixsu;p@02?ilo1KhLC_pf4vp_C$5-JV3B!?x5~6A~ z=uQyHpYc|vST})mQ~>3g1NK5_cT>*rod;CF-y{dcEoDo)bLm&+zJsWlMgX4=1B8|8 z*CTy>^;6DN_Db6{))7oCUtLv(*YlhjP~huZuBAn(jH5bZt~dfH?J%-L6e zmj{+J(!I17Z8T{}dzZMaZ008g1JNi1wVCn$zKJPGNl)sR&@#3$1Wb2u75M1qMZuk< zAx#he+EkmjK{lY?#Ng(~{GS???4tM&PEZjjI7KooAL~ov2~W75UAMb>UW0hYue!SX ztZ@{cboHfF*bI9*8LrrE6Vr5Tzv(oNCmK$8zG~no)BRHAwqtCumC6AN3U(0i$IcMT z@ON+$G$j98RNnO%OC=O7tZn%o-Be?;0#_OlZHSc6(HVSp$11ij+4YOP+!hPLobPatB5^rhrHl%QYtI``d{gd@%pDY162FnX|o-e{_%Iy_Amig zHMqAU9em#Jpb%4GRYs$LBSieXML8y0c#lrJU!MY(O$4I3ndn)Mh#}|)m4<=D5jR-% zqAu@X^)o0Su*coe5ZpZ8Cmkgnk3Ev4dcuaAZ^uNt&z3d zNMb^yi6qLC4M%ZI4MM4nKSt8~q{jyMR;+zK8Dp~*Tg zoKCe#s9X0qNLzb$YvL8khkk5x1kE`Rb2%;W=GZ{adZ%WwDw0eY*qy|8SZk3EYx8pf z4&yGaSCAyd1sAz$fyLR+tJbtP6|+UHOd~ABDE45GP7`< zI%Q@$CZz!Ida9c0v|!SEJp|nRmD$Q+1u_zB0%1mKY_nxl&MCfbpI`$i0mKeU#WE>i zy}F@y4!8u^><(khkfYNq_A`H&`5)y6&5%|#I&fVV+7-?)Af0u6xRegh3$ta|y1)em zTP+Y&eBFqY?k8uevM;>U0bkQK3BMiVWoGOdoS?reR_+I$KFd(0%7pK{Sv;y(2pMu5 zbG@Otoe8}ge#h42T}$Nc>6Oc=Vn9WF_6X~q2R$_iKTkm+G?P6mgh(yTU9ZJ)?=Vi# zR*wV%9m|At7HQuQL;PoWr^GkfLY1`;7`ULS?C4_9_z@#z4$3s77R01#TGK^cxbG1& zlbBIX9(LV6`F*IOY9yW;iaN2y7W=-ntW^U-kufthxmm%(8@kb$O$8l&mz8E-gs>kB z6%{;CyCR9_3bQ)sOXhb+4OeW9EC^;zEPQS>gDeIZ3|IYlW)}yl1PoPN-7U#7;1kf0^ z8Z(_T$&(`4%Gky#LKk5Or0|t3p~^ku7CKT{F`BPd6iY?p7kx;#<{B;BH?Za=sMzVu05E(DL1j?J?r5$s1d03XPS-DL~Xi)2(1-tXB`aU>Twa%|CO$_G{FVOuXwT9gMBzWelWtU;I4X*-y z^T2l#+ASzXZ&R*IQVIc_9M>6C=gF1`_Jsxn5eiSB<+{2YrWqROid(r`CqS4OE>KK@v7k_@mQOF?|-THv0 zh5Dw4UVo_%Dk7hJl6fvLgVe=R5jR``979JMhcsGy={9Apw-td=KlmY9dlXNW@B2Nm z9DN^%FLnEj*>1w6!!_+vbP?7R&J8`Pvl;7+t_TUEIY4?#ed zpv0VkB?<9wen1+Lol8_y@glLVOXF~g!aLhU+#i-4MEL9I5kV1@qy<^ga87JWG~t_3 z0SZL(V|dOa2_rvw8(T#0Ba9ew-YA>IoM0@g)!B#lt%;JbH*U(buOG<{mUp_+1pF}_ z36xiv2wgHPZn-DdNp*{fcgEpUd2Jwy=_MJU0K3XP4^4FmUhuUqjxosu>q}^Th6*>8 z2C*g$R#44k769K5;ePEUQ=cANOQH&S8@+wE9{u z>*Na~Nv&Rnc*(GP&a$09oZ90S6x(nVvy0qNkAbOH;ibeBdM7stfPE4Jq5Rkl^=8x&LZrt##dy8oSXEfA{0$oGI1@QAwUd7K6K1q5E6gZ4^Rx zHT*LVA(v`5(l8%CHC0m#ayw;^Qtyno&oRK-UE;>`t-|k-8k1)4HtfwM?M7onTK3uB zW()|6)cr?Sdc>WbUBCoJ8XND&ZWukkQNdRLMn0<<7PppT8*6$#euHG1i`r749iqLV zv)Ibvtm+Ot*zh1Ex?PGBja*HoQbDhg%R?*e79FHiKZA#M3#HzN53Vz2_FWa80$(AO zhzB+ogGgUI7m_WbCymmf^Qw1>u_fMb-^@N}?KgCA0F126i7m)K`yJ z0k}~q;l2wo-h5@()E>zAeBfb(c@YJS~l=j zk7wB;={mIc%!ygj5K<$qADnQSL*<;n$+8H@CO@TRfppQdZL7kDTYo!3)J)< zstWA`gGRQCmj}UvCZk~fNn1bLj1ggBd|?{p?UlJw73{A_#7#<0?RH!7mr0 zdPG{E@uSO%?91aoSOrPq3I@no1y9=t9xpG_(9zW;JKw%g96BN8N%x0paDONwn=Np7 z5M7&7#L4MAlu!*9rkD%}>T8Vxx6M&Wl@WMEyuiR64cF&1P&!8t7S!2*gRj!jl?}JQ zp`WExcJ{y5s~N=A8504XK4$Fd{S47pYL+M0Gh){mYT2eH$9Fy-TF=Of6L1Iz8xv_e zx*9^d$mm#LSNg?J>3p#OmB6^lc!8b`7wF3{T(F zYO`FRu9{D1wkn&^*3z^BcQux?>g$qdi?j2{M3&s&x;$qHO@MqujTHsun%(Hi3U1mjnos1ad2dO5ym zbe?_UIhtMp1-I52tGm*Z8~R5~90u7qZy=IcqD*Hy+|y?YH^MIEucd(a)I?qlMF~tF z9C%mw51O4ts3ffAuP|kv2o-NB%3oyiMdy7n57m+{5w!2HCv019! zxJ@3N?=CjV$uY>v5}6&Dr(X}YYEx!fJX-KPv0793n9ldcIeC}}usF9^7UpZ)2Th{S z8+1H){XD}4y3||V) zXppoGls;Dn38_(B+^d9u8K2km8-!tXra9SR)Nh*ue+B?_7gDBS7)gXK#h8ic@$7bw zZHkP9|3qu`$zQm?G2e+oZ z8$Qr2Mc&_{(&MM&fOj6RtZgQ`#1F|``Z_j(NZj`}3&>59_HOjd3d3YAUc*n+RTEq) z7alsvwHyncta&~~8D|7|&w_2O4eMO-ulH=Mou?_!N2d#RBeJVFGPI@K1M^dDg_ShH z8gxJ1JX>YMit%Mih@En)W9Y6%?!W0I43rK?1fb9v^$+p!N^+sh;(P11hBep;JcM8> z>eUZB8CU24x{BzY4V6hUYgj_8$?r)S$68cE#S#sMeXIFz7x77z-mbBKioO#!@$XA_ z5}(;eG{>tiCr%>12R<5d4g;VRExp;bt)angzGZwpC_QjL;(Mm0O--zE`1t;d24}2I zTAsQP2O!EHiUhbgS$Le+_+B^;I2WTYs%iz{8Fs%e!%k3`B&*R**gsbXi#-zaPXR<7 zHgsm1!(CRN3}dopPda6CE)m6y@Om|DOtAR;c)Y*QNy@VS_sd@*0x)CIs#||4=4J{h z+~|ZN@bnQ}y1zxj3IXYNidtu#1}}26g)x2~YN5hTJ6KxNVEDt>vCCm5DPQ}E*e9wI z46PSgw03}cM`*~zE5tc#}s1;ceMxD3Zr*k!g)!8<&ik~F439jK4Smt zP@YP75*;p^s>n!A0F5i?nNFD1mBGZNf(Pt0BxlaTs=xMK-rRg_|L~9JB!Xr|by1Vi zb05Fqy>8F71-#dNx&IJL`6liCD-OUs9>4yN6;mdT#ZLO_N&I0%FZNQTqJ7v8bY_+Y z&OYif7;^H<4`$Q|Y)8P*?7t#gj*2t3hNdcuzgF4R&^Ai57Y3!Db4riZ@|=-0WT+W_ zFTt{9Uvo7G81uae<2p&}1q(lzUCi|hOltW!H_V6_cP~MbfNvuK6md&QLtUI92(?WT z9nt#SjjcPa@tSazEQdMe4Mc%Q4*vb$2^;Il8Fs6gOvSnU*}s)-L0g4bq6D4Cy7c!7 zNf|8I=BnF0BklvUvOOZKKws9*G%%YQ)G`DHLy*^yXfLwWQ>}N`5q=t#kpjz%Q8&|i zoi*597Ug$O8Gz6ZX5cl46nu5A5-vPRC~sc6f7mexucUkdO02 zw|1=bZp}jMS&l*a89+*zzjKB9Js$v&?mYJq0l{PiPWI6Gx=Wl=hqjq81?u51aLB=r z_BcbH8lO&vmOTCuRaNVw4p5#jQ8DvZ19Z;|6wa;5ugUXmi>cGY)r~v>R{XiC91|FN zEyHqd9(g$pkcRZNYe1il9b)lCAv!tA;&tJdx58b2Y({l@8Pz%CM8gZo&D)lO%c)3B z@9Bz&2|c9XnoRXV!7Iu)9F^^bcM-O5yX9j?B4yLEr!g~&9>Ctk+6ns(P$&KW5_N^j z>gDj@j6}Bo?(ev`v(K>`;19tS)xh-BA<8yL4im!?$m7+98*q*r3`0qR?~&0Qo;+3Q z8bc^5t{Ad~Al&uBY}g!gC=hClOI|k`Y^9@J9?lkP?op!s{LhSFBnSH06vzfH68?Dj zy)Oy3+C~f%p5`E0**?2#Ksi6%5OrF~9(>mAfq5^2%zjrqUQN1M*5yg42j4D>rJDcy z2}Yj|U818nLQ%Yl3@euZi1C%Vgs-uoP9!E3mCG$4dZjK3M=QX%h7?5>nb#qcc1_aI>n&6@dFHr%|o& zX#&z<^iP+Rk2_N3zEcVIAg1zsCq^y)71n(qIca!+3**JHay`0v%+>Vlk`s1-jnb$6?Pf+F`#i>}LGhc7c8MoJyb-{U)9jBc8 zi*lsUmmg=pH3~Sht+a@R#S>Erb#Q-I+%NKo`6Rac}brZ(%$LVK;3vzX}>01LrKgY`kD1+Z_Pz8J^;?TSS! z`y_LJn3NS&K2gf*3o|)`F_?S4l`1dons#G0A|+CDAIi(_-u?WrScD|v9|Q;U?%&Er zdHrv{voS8)ox*r>*O~ywa9oE^dRxVM2JjSSR_Mg18#dwfO1|}>J(kAIR$A?eef8KU z3TfgB1RKJm;*9bU%}@NOAbG2IABn@SYLX-v>8Z3+zrUJV2jJ;p{eBQ}qdV&b*8#T) zvi)o|W}*7OLG~`EgZeF>mGS92(&8> z-t-GguY!$%^YM+|U1`&MjKJ2jcX2Kux_L@t0_!(*Pj?}fJNH#5;F+rwz4T*29!LX! zOl&UgNAcXc8LWs$SFc#!YjB$8A+|*X)S=V97_q%)Z#=RX#yT8)6(^dSZVs}hD1J+N z*VcY6O_xE$Zxa!}gFBBZ!F$NXB7u2_c!iX4+;)-@peMviNS&Hn1sA9CI?9UqNL;l+ z78|0b6p1r?$Qe&~$W;0Pt`Y-JjI)N4vetF(?& zUUHnH032;DTIFhw9g}%o0Q$pypM%({q{M z!yepE_`aNkd*F6k?>_*G44`gyl0Zi*IK4+6$v@5Lxk^d5xXhs+i}0?8jWqEQWwMoV>(y!o4|pYo=J%yPVX6^F59`(ErSvpC0E4hM z8v|V;v(UQ(j63N{+)X{37i5r#`*~^E-L(I_e2)67A0h1GDRx0W&#h-Q4CU}gLuQ!t zi>&A+;R0juUg1`J5wc_fWd!6cM~;~xePWV!>;`usj-*-huX@P(bR@F00Sn&s+h~P` zj5Hb_i)3C&l!%QdCqmcq0U>7=HNN1s8SWswWCv!J#%GYv83}4A$-QrJq}4!v95v(0 z9sw=$8IEGy6w3IL9{_{EkBojL@cCnh9*FEHCQ{*UVRReg2*Gj@IRo8`-i<~STxgE~ zzjiC-v0IcckH_T(f7QA^0trWUbDT_)@@u4Y4lJe<8uyk)&wggT>9@mMr{bh`7oozb zqHBeg2sYkL!V{;?7ECiE7Or53mYIE9O-|oSBT+!Kas;Jjp0R52?4K!;LDz#_J~~}0 zZ0wUsZbSm@3`lC8uV|nNf5m6lECr*xvn_>uUAF&ss`#WkVh!(S1EgjXTHi(fJ!@n~ z)Jzd_KzWi1KHEY}G&3mSXsC;>A2*PL%eXYb%X6OlQ!xZBFz5sjNLPZ^0{%^NmgySz0ZFusumX=& z7u4X9s2ZS^ViWW)$S4Z^%-pvks{lr)3j4?*jQWKS>mK@ML0+dAZWQ|p5y=DxZgov= z1Dbg%6#Gr!vdeD7jGceOSQa*r4p0_~=^7oYngfh6H_4s$8tN6SI+0H4&X`r^HH_}0 z1~xmd(m(45shzga44Wm$2@B;fos)SeO40a7Nr^EbROUCT6gkR=`i!mW*nna&0^ySe zd8Dem=v5;ie8Bj_zLDEbrzns;AH~Q@``Xqw5&YVe5#FJ;yt^e?ftJ(WR=iar|@L06%8vNf8_#!>j7-zIQ z`4buKOPHJ9Yr2&wX+N0a|A3oYXp8C_FN!2jLSvgtEcauC_yU0dxnbXwaGJR_0z zBy_Vr+J2iY8)g(Mc-j{RJ@e4JCO8kZTLT!S;}{G}6jsx)+hE4Ar&Paaw~3x6xS~@Z zaQw0fT>mZpDDQe+7p#b+>0Lw!Xo5O^;T(j~s; zORVrCy!%FdZ$#?*^Xi{(xPD7sKl_J<9yRd6nb0<=p$>LguM~z)XbLyzNIgKhZjh~N z4K6Y00&T;yd*z6*C%keOltCJwn|~v_D=rz|ry`Hk$xZNx5|S2$@PX3W#E8kL2Zp6g zuah30_I7I6et&2Y{fe>$4ye;yOG;W#$R5zALk$0}g{iB$2XN>YM?#*>NETk+BVOtI zhwOwmr&zDSp8*F9{{2C+?Kk&(Kc5S(*{lI;A;u(O;m+KwGzR2JUB~kpy#1mHA-Gux zu2zVZiSQQM6#t-qQ>C3-hmsB3nHO?Akl)=nc8S}U;VZMGea0Z?Bdvd8u#LPbM{%po z`F~v5*4;iA&-^*qEXdpBf1bUl5itNg;EqU1d<96fy)w)Q^Q6a1Pzcc_Z9G3)NB@F( z4ywBy`d}-co^V;gAO0YCU2Ig){uehn#lb1ros!#90T2WOyFtAsWUKlcPmF}xzlOVk z$W|!Kfsgu;x*$h|6)8fbtMt?@hqm}2sFwQvOTJjkoo)e6z`eZS?qaHp7CU}9LmMz#7#$AAXjYSOhi)} zR|9V;sp-Po5L=e)ux!yPX9I~%)Qtetm)ZgJ=HZpEfQ@HpW{uUY3rbf3AU>q9U_c>J zCcu66xs1n6%{rkZ)q3+utvB~Q7KeXr{V3CeThxBH6qA!3?wlTlBxWHwUy+0*@Xls< z844kKV07lr7&1+*BMOWB$}wMnT)0BTQE)FrN^?O$AfV#rROqAy3z}GvE%usu5N3>Z z&T^1^w;$DR2I`<;6Ri*lYwmK7*B2*nP|V=W1&=W0zXOENf>oqA{RHrO6#pKC+Ze?*xT4#5edt? zc# z&q01>7CW)%2q@Od=bBG)4{s}~bk#~g5oxsr{^ta4orG9}Smwz7gls~#3cJkk6fL$- z&Z}6KI$^p~pGP%U6iIV;K_gNjm4eo->zo<5)xa>;3`7(?SU#MD=HWPvVI&5 zf@vNT>~|RhWj!lcYBg7vv-rx2d>u z#-?O5Y2S>HA$8m0)mpGeuYy*3+D8Q$kWONNkg z04SODF&})K$aOx~?k8EQjrC6x=m$g@{bHs;WscsQ6l1AK5tP^+c!EmavBsZ9TNKa1 zqOE^3H#dxh;M5cX8&>NLQ?cS&bt}O>@1~gwF*OibG%mYiNQ8h#zMqJ(1kzRB5gW#D z+6{ImiSr#!9-p|2?0zJk7^8OUH0(Su7eLGw}I6T9H$_CvKgx*6JPo@e<@)2GbZ|Wo(Oah!fe|OgQF3YO*IqDWeFh z-dF2K!ww}LH1iHnSsj`(sB6Uwt0h3L=*mPni` z7Yfs%x61%tcc>bH`FKS;c%Q627fUAXarVRBM@|p!#}Y2QHeBCdQ(;1Q?sapjJ#E?i z9^KiiY`)La^4l#uezfL7yevGmaae8vPG*`PWfaz>8H3^57WaWd8rdeRc+dQy64@9zBZSL1l*$jee87p(*Y;=YQYxZwM*_$Pr#4>CmNNKaU4 zVpcUJnS_$R7l@1_lda{-o2ZgAF%IFU9DXAvfR#HW#UTThk3j2Q1?EsREz_e?*nu_P zw&|S@1h&%7pQdrob;S$^-|R-hY#bB4n&wvEYeMkS+bM2b5Qp`MxU3{_J?)Lw0_I;% zA3vC<^&y3;&k}=eCa9CT4*j_GOH(!n+34koECX3JZO6ua=1@LbcK*w(vKKI$*C|_9}GA3 z&X!HxBL*J6DD8dhbHEiY$#6XC7lbto!a&^xb+y}HZhxbP1C!!AuRqgxQuDRNwx`@1 zuhytTqbGVsv_#w^=pUzC1f!@2EzRWc%`S;ew9YyxqDTF?K#AN3?R?A;^LW0Q-N@fJHA=b%Tx69iG!`5 zo!|7FpEk=W5LUYT32Kv$?3K4!j~vK^LGK0|qEvJ;R%2!g!Wa!+vf*Nk zBwG~J#HIy)fcj};1Y=^rR0u(&93b1@1rN0oj9o)#6*EChN<(tHXYM{f5-g$+DfH$? zp;1QnB4daWH9qYK4AIF7YHHr0C>_!$++n}$URWq^Kkwc`0)PR`r<@}n7RZasGkpZH zAToIUY4?{EW zFpNwv5y7zTy7jD*L???hMwjpije`A!#m91hB(aP-?oCCV1iE6XJQf*)D$;AFR)(#; z_i{R#WPvRV`ai<)C``-~&FrM7(PWsYH3A{BM6?IkEw0Mwba zuA$A|e1iLm)Z&4njO0vfk8<2v2!Om%!9y);rKirow100{hK{VL2% zkJIEW7IW4jVx6#3*N?tpR!B~m_ZsIlL&%9wgD(e^D(Xb$4O&_bt-!`884gr;AOzpZt)eR zXe4DCmUUkeWtuaVTx%Dnq(G0#BjN&3PLOn3yM)tO?}%V*Q!`&53It44jp>BxjIXlbkDp5n}@v0x`rqmB`_SbHeXB*f=1+}l#5s8c;}o(!OorAb!G{QG@g>!>w8 z4537#6@y5e!WMvB((YIGRVaf+YK`X|68&@PG!O(5{MN~{2rq@NMb0tqBu0vRxkeV( zMWun-n=y4g*|6zCOiTfj6lY05FR-=Ac2xYhu7vsY_+sk0KUzbf{m8a@1sgc;xIsTBx9>IIE+a_5a#gA7T2Qr@*XE7?2vT*zC4%ly=}gw@=p% zvfJ?DYopms0$I-PW37Pn`3qwQcG{h1B-y4-reDF438au#{oH-{1&m*HIRi;xcMk^G zvNCh|VerV2DMU-S_4E}zKEiKjAci$$@^4K&n&^%yQqgcQ<0+C2UCXgKr!{D$p)~Zp z1gZ}SvTKg#3zK!nt&3Cx>-{OUGdl5!bc|eUXBF`R1V6M>Gp2}Mh#^YXoK06SahIHrF@2W(kBo7V6{8!ioV;KflLm6{>J;Y~A463MX)lQi9qc zYd7N?&lzEQxx}*B{U%@oUKCq7O2%?sAm=f?{V^+X5I{5|smn^+PFEuSXdnkJ-)gP5 z#j8tWAG9&wnHvm~g<9mX%7=D~OqgpyH)i3vgg(-*OA>Gw!>%KrC&GOSzq z2^E|^;{-)72z7>-xqK26L}MB)*5jG;SYr4~GdJO~OhM62F4yc68L02sHkN60ZcSo< zh0Kz>VjqIq?l6Tc$8|??q`@uoBDvLw`{DVWcC-?o{K@9r$n(NOWTJv^1Z;C)=x23g zBL2bM5Et_;kZwI$n3NedEGg!F9lUh)0>*Wwk=3B7B7-@}l>U~bkMYO_EaAEzb}c>N z9Q?A2H6QQfru|2e0~luD&Rl9|^dE~_yvlV>##*Tr(~WA7{xddC}5Tv zndZ|^@pnEVOC-wJ_On`QMki8=?_;if&@y?tC+_%=4=n;x7m4^PRinPm|&|63ewRccdh3q-y;mZkn+hIk8UkBV$E z9w*_R_s(H?K9c{IM%>KGDAl??o^Z@wnyEMY43)>}RCtl`V_3DIYPd}o PrwOY*-g1h^plsaB|2yQh literal 0 HcmV?d00001 diff --git a/packages/react-native/src/ReactNativeFileSystem.ts b/packages/react-native/src/ReactNativeFileSystem.ts index 0eaab55429..bf8e9fb353 100644 --- a/packages/react-native/src/ReactNativeFileSystem.ts +++ b/packages/react-native/src/ReactNativeFileSystem.ts @@ -1,6 +1,6 @@ -import type { FileSystem } from '@aries-framework/core' +import type { FileSystem, DownloadToFileOptions } from '@aries-framework/core' -import { getDirFromFilePath } from '@aries-framework/core' +import { TypedArrayEncoder, AriesFrameworkError, getDirFromFilePath, Buffer } from '@aries-framework/core' import * as RNFS from 'react-native-fs' export class ReactNativeFileSystem implements FileSystem { @@ -36,7 +36,7 @@ export class ReactNativeFileSystem implements FileSystem { return RNFS.readFile(path, 'utf8') } - public async downloadToFile(url: string, path: string) { + public async downloadToFile(url: string, path: string, options?: DownloadToFileOptions) { // Make sure parent directories exist await RNFS.mkdir(getDirFromFilePath(path)) @@ -46,5 +46,21 @@ export class ReactNativeFileSystem implements FileSystem { }) await promise + + if (options?.verifyHash) { + // RNFS returns hash as HEX + const fileHash = await RNFS.hash(path, options.verifyHash.algorithm) + const fileHashBuffer = Buffer.from(fileHash, 'hex') + + // If hash doesn't match, remove file and throw error + if (fileHashBuffer.compare(options.verifyHash.hash) !== 0) { + await RNFS.unlink(path) + throw new AriesFrameworkError( + `Hash of downloaded file does not match expected hash. Expected: ${TypedArrayEncoder.toBase58( + options.verifyHash.hash + )}, Actual: ${TypedArrayEncoder.toBase58(fileHashBuffer)}` + ) + } + } } }