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 0000000000..73f0471860 Binary files /dev/null and b/packages/node/tests/__fixtures__/tailsFile differ 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)}` + ) + } + } } }