diff --git a/packages/core/src/modules/credentials/CredentialServiceOptions.ts b/packages/core/src/modules/credentials/CredentialServiceOptions.ts index b1fb78333f..2bcb4de306 100644 --- a/packages/core/src/modules/credentials/CredentialServiceOptions.ts +++ b/packages/core/src/modules/credentials/CredentialServiceOptions.ts @@ -1,9 +1,62 @@ import type { AgentMessage } from '../../agent/AgentMessage' import type { ConnectionRecord } from '../connections/repository/ConnectionRecord' import type { CredentialFormat, CredentialFormatPayload } from './formats' +import type { CredentialPreviewAttributeOptions } from './models' import type { AutoAcceptCredential } from './models/CredentialAutoAcceptType' import type { CredentialExchangeRecord } from './repository/CredentialExchangeRecord' +/** + * Get the format data payload for a specific message from a list of CredentialFormat interfaces and a message + * + * For an indy offer, this resolves to the cred abstract format as defined here: + * https://github.com/hyperledger/aries-rfcs/tree/b3a3942ef052039e73cd23d847f42947f8287da2/features/0592-indy-attachments#cred-abstract-format + * + * @example + * ``` + * + * type OfferFormatData = FormatDataMessagePayload<[IndyCredentialFormat, JsonLdCredentialFormat], 'offer'> + * + * // equal to + * type OfferFormatData = { + * indy: { + * // ... payload for indy offer attachment as defined in RFC 0592 ... + * }, + * jsonld: { + * // ... payload for jsonld offer attachment as defined in RFC 0593 ... + * } + * } + * ``` + */ +export type FormatDataMessagePayload< + CFs extends CredentialFormat[] = CredentialFormat[], + M extends keyof CredentialFormat['formatData'] = keyof CredentialFormat['formatData'] +> = { + [CredentialFormat in CFs[number] as CredentialFormat['formatKey']]?: CredentialFormat['formatData'][M] +} + +/** + * Get format data return value. Each key holds a mapping of credential format key to format data. + * + * @example + * ``` + * { + * proposal: { + * indy: { + * cred_def_id: string + * } + * } + * } + * ``` + */ +export type GetFormatDataReturn = { + proposalAttributes?: CredentialPreviewAttributeOptions[] + proposal?: FormatDataMessagePayload + offer?: FormatDataMessagePayload + offerAttributes?: CredentialPreviewAttributeOptions[] + request?: FormatDataMessagePayload + credential?: FormatDataMessagePayload +} + export interface CreateProposalOptions { connection: ConnectionRecord credentialFormats: CredentialFormatPayload diff --git a/packages/core/src/modules/credentials/CredentialsModule.ts b/packages/core/src/modules/credentials/CredentialsModule.ts index 38e15d199d..05eb6596f0 100644 --- a/packages/core/src/modules/credentials/CredentialsModule.ts +++ b/packages/core/src/modules/credentials/CredentialsModule.ts @@ -12,6 +12,7 @@ import type { ProposeCredentialOptions, ServiceMap, CreateOfferOptions, + GetFormatDataReturn, } from './CredentialsModuleOptions' import type { CredentialFormat } from './formats' import type { IndyCredentialFormat } from './formats/indy/IndyCredentialFormat' @@ -68,6 +69,7 @@ export interface CredentialsModule findById(credentialRecordId: string): Promise deleteById(credentialRecordId: string, options?: DeleteCredentialOptions): Promise + getFormatData(credentialRecordId: string): Promise> } @scoped(Lifecycle.ContainerScoped) @@ -505,6 +507,13 @@ export class CredentialsModule< } } + public async getFormatData(credentialRecordId: string): Promise> { + const credentialRecord = await this.getById(credentialRecordId) + const service = this.getService(credentialRecord.protocolVersion) + + return service.getFormatData(credentialRecordId) + } + /** * Retrieve a credential record by id * diff --git a/packages/core/src/modules/credentials/CredentialsModuleOptions.ts b/packages/core/src/modules/credentials/CredentialsModuleOptions.ts index f5627124a4..c26f0befc9 100644 --- a/packages/core/src/modules/credentials/CredentialsModuleOptions.ts +++ b/packages/core/src/modules/credentials/CredentialsModuleOptions.ts @@ -1,7 +1,11 @@ +import type { GetFormatDataReturn } from './CredentialServiceOptions' import type { CredentialFormat, CredentialFormatPayload } from './formats' import type { AutoAcceptCredential } from './models/CredentialAutoAcceptType' import type { CredentialService } from './services' +// re-export GetFormatDataReturn type from service, as it is also used in the module +export type { GetFormatDataReturn } + /** * Get the supported protocol versions based on the provided credential services. */ diff --git a/packages/core/src/modules/credentials/formats/CredentialFormat.ts b/packages/core/src/modules/credentials/formats/CredentialFormat.ts index 25e5dda8d0..add9c51212 100644 --- a/packages/core/src/modules/credentials/formats/CredentialFormat.ts +++ b/packages/core/src/modules/credentials/formats/CredentialFormat.ts @@ -35,4 +35,10 @@ export interface CredentialFormat { createRequest: unknown acceptRequest: unknown } + formatData: { + proposal: unknown + offer: unknown + request: unknown + credential: unknown + } } diff --git a/packages/core/src/modules/credentials/formats/indy/IndyCredentialFormat.ts b/packages/core/src/modules/credentials/formats/indy/IndyCredentialFormat.ts index a31bf2863c..527e37518e 100644 --- a/packages/core/src/modules/credentials/formats/indy/IndyCredentialFormat.ts +++ b/packages/core/src/modules/credentials/formats/indy/IndyCredentialFormat.ts @@ -2,6 +2,7 @@ import type { LinkedAttachment } from '../../../../utils/LinkedAttachment' import type { CredentialPreviewAttributeOptions } from '../../models' import type { CredentialFormat } from '../CredentialFormat' import type { IndyCredProposeOptions } from './models/IndyCredPropose' +import type { Cred, CredOffer, CredReq } from 'indy-sdk' /** * This defines the module payload for calling CredentialsModule.createProposal @@ -51,4 +52,19 @@ export interface IndyCredentialFormat extends CredentialFormat { createRequest: never // cannot start from createRequest acceptRequest: Record // empty object } + // Format data is based on RFC 0592 + // https://github.com/hyperledger/aries-rfcs/tree/main/features/0592-indy-attachments + formatData: { + proposal: { + schema_issuer_did?: string + schema_name?: string + schema_version?: string + schema_id?: string + issuer_did?: string + cred_def_id?: string + } + offer: CredOffer + request: CredReq + credential: Cred + } } diff --git a/packages/core/src/modules/credentials/protocol/v1/V1CredentialService.ts b/packages/core/src/modules/credentials/protocol/v1/V1CredentialService.ts index 76f993620a..89da46d630 100644 --- a/packages/core/src/modules/credentials/protocol/v1/V1CredentialService.ts +++ b/packages/core/src/modules/credentials/protocol/v1/V1CredentialService.ts @@ -12,6 +12,8 @@ import type { NegotiateOfferOptions, NegotiateProposalOptions, } from '../../CredentialServiceOptions' +import type { GetFormatDataReturn } from '../../CredentialsModuleOptions' +import type { CredentialFormat } from '../../formats' import type { IndyCredentialFormat } from '../../formats/indy/IndyCredentialFormat' import { Lifecycle, scoped } from 'tsyringe' @@ -210,7 +212,11 @@ export class V1CredentialService extends CredentialService<[IndyCredentialFormat await this.formatService.processProposal({ credentialRecord, - attachment: this.rfc0592ProposalAttachmentFromV1ProposeMessage(proposalMessage), + attachment: new Attachment({ + data: new AttachmentData({ + json: JsonTransformer.toJSON(this.rfc0592ProposalFromV1ProposeMessage(proposalMessage)), + }), + }), }) // Update record @@ -279,7 +285,11 @@ export class V1CredentialService extends CredentialService<[IndyCredentialFormat attachId: INDY_CREDENTIAL_OFFER_ATTACHMENT_ID, credentialFormats, credentialRecord, - proposalAttachment: this.rfc0592ProposalAttachmentFromV1ProposeMessage(proposalMessage), + proposalAttachment: new Attachment({ + data: new AttachmentData({ + json: JsonTransformer.toJSON(this.rfc0592ProposalFromV1ProposeMessage(proposalMessage)), + }), + }), }) if (!previewAttributes) { @@ -1045,6 +1055,49 @@ export class V1CredentialService extends CredentialService<[IndyCredentialFormat }) } + public async getFormatData(credentialExchangeId: string): Promise> { + // TODO: we could looking at fetching all record using a single query and then filtering based on the type of the message. + const [proposalMessage, offerMessage, requestMessage, credentialMessage] = await Promise.all([ + this.findProposalMessage(credentialExchangeId), + this.findOfferMessage(credentialExchangeId), + this.findRequestMessage(credentialExchangeId), + this.findCredentialMessage(credentialExchangeId), + ]) + + const indyProposal = proposalMessage + ? JsonTransformer.toJSON(this.rfc0592ProposalFromV1ProposeMessage(proposalMessage)) + : undefined + + const indyOffer = offerMessage?.indyCredentialOffer ?? undefined + const indyRequest = requestMessage?.indyCredentialRequest ?? undefined + const indyCredential = credentialMessage?.indyCredential ?? undefined + + return { + proposalAttributes: proposalMessage?.credentialProposal?.attributes, + proposal: proposalMessage + ? { + indy: indyProposal, + } + : undefined, + offerAttributes: offerMessage?.credentialPreview?.attributes, + offer: offerMessage + ? { + indy: indyOffer, + } + : undefined, + request: requestMessage + ? { + indy: indyRequest, + } + : undefined, + credential: credentialMessage + ? { + indy: indyCredential, + } + : undefined, + } + } + protected registerHandlers() { this.dispatcher.registerHandler(new V1ProposeCredentialHandler(this, this.agentConfig)) this.dispatcher.registerHandler( @@ -1063,7 +1116,7 @@ export class V1CredentialService extends CredentialService<[IndyCredentialFormat this.dispatcher.registerHandler(new V1CredentialProblemReportHandler(this)) } - private rfc0592ProposalAttachmentFromV1ProposeMessage(proposalMessage: V1ProposeCredentialMessage) { + private rfc0592ProposalFromV1ProposeMessage(proposalMessage: V1ProposeCredentialMessage) { const indyCredentialProposal = new IndyCredPropose({ credentialDefinitionId: proposalMessage.credentialDefinitionId, schemaId: proposalMessage.schemaId, @@ -1073,11 +1126,7 @@ export class V1CredentialService extends CredentialService<[IndyCredentialFormat schemaVersion: proposalMessage.schemaVersion, }) - return new Attachment({ - data: new AttachmentData({ - json: JsonTransformer.toJSON(indyCredentialProposal), - }), - }) + return indyCredentialProposal } private assertOnlyIndyFormat(credentialFormats: Record) { diff --git a/packages/core/src/modules/credentials/protocol/v1/__tests__/v1-credentials.e2e.test.ts b/packages/core/src/modules/credentials/protocol/v1/__tests__/v1-credentials.e2e.test.ts index c3c17ed80c..40978f7156 100644 --- a/packages/core/src/modules/credentials/protocol/v1/__tests__/v1-credentials.e2e.test.ts +++ b/packages/core/src/modules/credentials/protocol/v1/__tests__/v1-credentials.e2e.test.ts @@ -180,5 +180,107 @@ describe('v1 credentials', () => { threadId: faberCredentialRecord.threadId, state: CredentialState.Done, }) + + const formatData = await aliceAgent.credentials.getFormatData(aliceCredentialRecord.id) + + expect(formatData).toMatchObject({ + proposalAttributes: [ + { + name: 'name', + mimeType: 'text/plain', + value: 'John', + }, + { + name: 'age', + mimeType: 'text/plain', + value: '99', + }, + { + name: 'x-ray', + mimeType: 'text/plain', + value: 'some x-ray', + }, + { + name: 'profile_picture', + mimeType: 'text/plain', + value: 'profile picture', + }, + ], + proposal: { + indy: { + schema_issuer_did: expect.any(String), + schema_id: expect.any(String), + schema_name: expect.any(String), + schema_version: expect.any(String), + cred_def_id: expect.any(String), + issuer_did: expect.any(String), + }, + }, + offer: { + indy: { + schema_id: expect.any(String), + cred_def_id: expect.any(String), + key_correctness_proof: expect.any(Object), + nonce: expect.any(String), + }, + }, + offerAttributes: [ + { + name: 'name', + mimeType: 'text/plain', + value: 'John', + }, + { + name: 'age', + mimeType: 'text/plain', + value: '99', + }, + { + name: 'x-ray', + mimeType: 'text/plain', + value: 'some x-ray', + }, + { + name: 'profile_picture', + mimeType: 'text/plain', + value: 'profile picture', + }, + ], + request: { + indy: { + prover_did: expect.any(String), + cred_def_id: expect.any(String), + blinded_ms: expect.any(Object), + blinded_ms_correctness_proof: expect.any(Object), + nonce: expect.any(String), + }, + }, + credential: { + indy: { + schema_id: expect.any(String), + cred_def_id: expect.any(String), + rev_reg_id: null, + values: { + age: { raw: '99', encoded: '99' }, + profile_picture: { + raw: 'profile picture', + encoded: '28661874965215723474150257281172102867522547934697168414362313592277831163345', + }, + name: { + raw: 'John', + encoded: '76355713903561865866741292988746191972523015098789458240077478826513114743258', + }, + 'x-ray': { + raw: 'some x-ray', + encoded: '43715611391396952879378357808399363551139229809726238083934532929974486114650', + }, + }, + signature: expect.any(Object), + signature_correctness_proof: expect.any(Object), + rev_reg: null, + witness: null, + }, + }, + }) }) }) diff --git a/packages/core/src/modules/credentials/protocol/v2/V2CredentialService.ts b/packages/core/src/modules/credentials/protocol/v2/V2CredentialService.ts index 8caecb33a0..f4712c39ab 100644 --- a/packages/core/src/modules/credentials/protocol/v2/V2CredentialService.ts +++ b/packages/core/src/modules/credentials/protocol/v2/V2CredentialService.ts @@ -12,6 +12,8 @@ import type { CreateRequestOptions, AcceptRequestOptions, AcceptCredentialOptions, + GetFormatDataReturn, + FormatDataMessagePayload, } from '../../CredentialServiceOptions' import type { CredentialFormat, @@ -1063,6 +1065,53 @@ export class V2CredentialService { + // TODO: we could looking at fetching all record using a single query and then filtering based on the type of the message. + const [proposalMessage, offerMessage, requestMessage, credentialMessage] = await Promise.all([ + this.findProposalMessage(credentialExchangeId), + this.findOfferMessage(credentialExchangeId), + this.findRequestMessage(credentialExchangeId), + this.findCredentialMessage(credentialExchangeId), + ]) + + // Create object with the keys and the message formats/attachments. We can then loop over this in a generic + // way so we don't have to add the same operation code four times + const messages = { + proposal: [proposalMessage?.formats, proposalMessage?.proposalAttachments], + offer: [offerMessage?.formats, offerMessage?.offerAttachments], + request: [requestMessage?.formats, requestMessage?.requestAttachments], + credential: [credentialMessage?.formats, credentialMessage?.credentialAttachments], + } as const + + const formatData: GetFormatDataReturn = { + proposalAttributes: proposalMessage?.credentialPreview?.attributes, + offerAttributes: offerMessage?.credentialPreview?.attributes, + } + + // We loop through all of the message keys as defined above + for (const [messageKey, [formats, attachments]] of Object.entries(messages)) { + // Message can be undefined, so we continue if it is not defined + if (!formats || !attachments) continue + + // Find all format services associated with the message + const formatServices = this.getFormatServicesFromMessage(formats) + const messageFormatData: FormatDataMessagePayload = {} + + // Loop through all of the format services, for each we will extract the attachment data and assign this to the object + // using the unique format key (e.g. indy) + for (const formatService of formatServices) { + const attachment = this.credentialFormatCoordinator.getAttachmentForService(formatService, formats, attachments) + + messageFormatData[formatService.formatKey] = attachment.getDataAsJson() + } + + formatData[messageKey as Exclude] = + messageFormatData + } + + return formatData + } + protected registerHandlers() { this.logger.debug('Registering V2 handlers') diff --git a/packages/core/src/modules/credentials/protocol/v2/__tests__/v2-credentials.e2e.test.ts b/packages/core/src/modules/credentials/protocol/v2/__tests__/v2-credentials.e2e.test.ts index 316524f62e..3a2cb288ca 100644 --- a/packages/core/src/modules/credentials/protocol/v2/__tests__/v2-credentials.e2e.test.ts +++ b/packages/core/src/modules/credentials/protocol/v2/__tests__/v2-credentials.e2e.test.ts @@ -475,6 +475,108 @@ describe('v2 credentials', () => { threadId: faberCredentialRecord.threadId, state: CredentialState.CredentialReceived, }) + + const formatData = await aliceAgent.credentials.getFormatData(aliceCredentialRecord.id) + + expect(formatData).toMatchObject({ + proposalAttributes: [ + { + name: 'name', + mimeType: 'text/plain', + value: 'John', + }, + { + name: 'age', + mimeType: 'text/plain', + value: '99', + }, + { + name: 'x-ray', + mimeType: 'text/plain', + value: 'another x-ray value', + }, + { + name: 'profile_picture', + mimeType: 'text/plain', + value: 'another profile picture', + }, + ], + proposal: { + indy: { + schema_issuer_did: expect.any(String), + schema_id: expect.any(String), + schema_name: expect.any(String), + schema_version: expect.any(String), + cred_def_id: expect.any(String), + issuer_did: expect.any(String), + }, + }, + offer: { + indy: { + schema_id: expect.any(String), + cred_def_id: expect.any(String), + key_correctness_proof: expect.any(Object), + nonce: expect.any(String), + }, + }, + offerAttributes: [ + { + name: 'name', + mimeType: 'text/plain', + value: 'John', + }, + { + name: 'age', + mimeType: 'text/plain', + value: '99', + }, + { + name: 'x-ray', + mimeType: 'text/plain', + value: 'some x-ray', + }, + { + name: 'profile_picture', + mimeType: 'text/plain', + value: 'profile picture', + }, + ], + request: { + indy: { + prover_did: expect.any(String), + cred_def_id: expect.any(String), + blinded_ms: expect.any(Object), + blinded_ms_correctness_proof: expect.any(Object), + nonce: expect.any(String), + }, + }, + credential: { + indy: { + schema_id: expect.any(String), + cred_def_id: expect.any(String), + rev_reg_id: null, + values: { + age: { raw: '99', encoded: '99' }, + profile_picture: { + raw: 'profile picture', + encoded: '28661874965215723474150257281172102867522547934697168414362313592277831163345', + }, + name: { + raw: 'John', + encoded: '76355713903561865866741292988746191972523015098789458240077478826513114743258', + }, + 'x-ray': { + raw: 'some x-ray', + encoded: '43715611391396952879378357808399363551139229809726238083934532929974486114650', + }, + }, + signature: expect.any(Object), + signature_correctness_proof: expect.any(Object), + rev_reg: null, + witness: null, + }, + }, + }) }) test('Faber starts with V2 offer, alice declines the offer', async () => { diff --git a/packages/core/src/modules/credentials/services/CredentialService.ts b/packages/core/src/modules/credentials/services/CredentialService.ts index c8fa9364e0..a83f5d76fc 100644 --- a/packages/core/src/modules/credentials/services/CredentialService.ts +++ b/packages/core/src/modules/credentials/services/CredentialService.ts @@ -19,6 +19,7 @@ import type { AcceptOfferOptions, AcceptRequestOptions, AcceptCredentialOptions, + GetFormatDataReturn, } from '../CredentialServiceOptions' import type { CredentialFormat, CredentialFormatService } from '../formats' import type { CredentialExchangeRecord, CredentialRepository } from './../repository' @@ -87,6 +88,7 @@ export abstract class CredentialService abstract findRequestMessage(credentialExchangeId: string): Promise abstract findCredentialMessage(credentialExchangeId: string): Promise + abstract getFormatData(credentialExchangeId: string): Promise> /** * Decline a credential offer