From ac073cf31a6d994a2b6a525dd5a3a617ff877d8e Mon Sep 17 00:00:00 2001 From: volodymyr-basiuk <31999965+volodymyr-basiuk@users.noreply.github.com> Date: Thu, 4 Apr 2024 17:47:06 +0300 Subject: [PATCH] add proposal-request (#208) * add proposal-request --- package-lock.json | 4 +- package.json | 2 +- src/iden3comm/constants.ts | 6 +- src/iden3comm/handlers/credential-proposal.ts | 302 ++++++++++++++++++ src/iden3comm/handlers/index.ts | 1 + src/iden3comm/types/index.ts | 1 + .../types/protocol/proposal-request.ts | 38 +++ tests/handlers/credential-proposal.test.ts | 241 ++++++++++++++ 8 files changed, 591 insertions(+), 4 deletions(-) create mode 100644 src/iden3comm/handlers/credential-proposal.ts create mode 100644 src/iden3comm/types/protocol/proposal-request.ts create mode 100644 tests/handlers/credential-proposal.test.ts diff --git a/package-lock.json b/package-lock.json index 553235d9..d0583254 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "@0xpolygonid/js-sdk", - "version": "1.10.0", + "version": "1.10.1", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "@0xpolygonid/js-sdk", - "version": "1.10.0", + "version": "1.10.1", "license": "AGPL-3.0", "dependencies": { "@noble/curves": "^1.4.0", diff --git a/package.json b/package.json index f325ccae..59b2f281 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@0xpolygonid/js-sdk", - "version": "1.10.0", + "version": "1.10.1", "description": "SDK to work with Polygon ID", "main": "dist/node/cjs/index.js", "module": "dist/node/esm/index.js", diff --git a/src/iden3comm/constants.ts b/src/iden3comm/constants.ts index 67399031..8930f492 100644 --- a/src/iden3comm/constants.ts +++ b/src/iden3comm/constants.ts @@ -32,7 +32,11 @@ export const PROTOCOL_MESSAGE_TYPE = Object.freeze({ // ContractInvokeRequestMessageType is type for request of contract invoke request CONTRACT_INVOKE_REQUEST_MESSAGE_TYPE: IDEN3_PROTOCOL + 'proofs/1.0/contract-invoke-request', // CredentialOnchainOfferMessageType is type of message with credential onchain offering - CREDENTIAL_ONCHAIN_OFFER_MESSAGE_TYPE: IDEN3_PROTOCOL + 'credentials/1.0/onchain-offer' + CREDENTIAL_ONCHAIN_OFFER_MESSAGE_TYPE: IDEN3_PROTOCOL + 'credentials/1.0/onchain-offer', + // ProposalRequestMessageType is type for proposal-request message + PROPOSAL_REQUEST_MESSAGE_TYPE: IDEN3_PROTOCOL + 'credentials/0.1/proposal-request', + // ProposalMessageType is type for proposal message + PROPOSAL_MESSAGE_TYPE: IDEN3_PROTOCOL + 'credentials/0.1/proposal' }); /** diff --git a/src/iden3comm/handlers/credential-proposal.ts b/src/iden3comm/handlers/credential-proposal.ts new file mode 100644 index 00000000..40586725 --- /dev/null +++ b/src/iden3comm/handlers/credential-proposal.ts @@ -0,0 +1,302 @@ +import { PROTOCOL_MESSAGE_TYPE } from '../constants'; +import { MediaType } from '../constants'; +import { + CredentialOffer, + CredentialsOfferMessage, + IPackageManager, + JSONObject, + PackerParams +} from '../types'; + +import { DID } from '@iden3/js-iden3-core'; +import * as uuid from 'uuid'; +import { proving } from '@iden3/js-jwz'; +import { + Proposal, + ProposalRequestCredential, + ProposalRequestMessage, + ProposalMessage +} from '../types/protocol/proposal-request'; +import { IIdentityWallet } from '../../identity'; +import { byteEncoder } from '../../utils'; +import { W3CCredential } from '../../verifiable'; + +/** @beta ProposalRequestCreationOptions represents proposal-request creation options */ +export type ProposalRequestCreationOptions = { + credentials: ProposalRequestCredential[]; + metadata?: { type: string; data?: JSONObject }; + did_doc?: JSONObject; +}; + +/** + * @beta + * createProposalRequest is a function to create protocol proposal-request protocol message + * @param {DID} sender - sender did + * @param {DID} receiver - receiver did + * @param {ProposalRequestCreationOptions} opts - creation options + * @returns `Promise` + */ +export function createProposalRequest( + sender: DID, + receiver: DID, + opts: ProposalRequestCreationOptions +): ProposalRequestMessage { + const uuidv4 = uuid.v4(); + const request: ProposalRequestMessage = { + id: uuidv4, + thid: uuidv4, + from: sender.string(), + to: receiver.string(), + typ: MediaType.PlainMessage, + type: PROTOCOL_MESSAGE_TYPE.PROPOSAL_REQUEST_MESSAGE_TYPE, + body: opts + }; + return request; +} + +/** + * @beta + * createProposal is a function to create protocol proposal protocol message + * @param {DID} sender - sender did + * @param {DID} receiver - receiver did + * @param {Proposal[]} proposals - proposals + * @returns `Promise` + */ +export function createProposal( + sender: DID, + receiver: DID, + proposals?: Proposal[] +): ProposalMessage { + const uuidv4 = uuid.v4(); + const request: ProposalMessage = { + id: uuidv4, + thid: uuidv4, + from: sender.string(), + to: receiver.string(), + typ: MediaType.PlainMessage, + type: PROTOCOL_MESSAGE_TYPE.PROPOSAL_MESSAGE_TYPE, + body: { + proposals: proposals || [] + } + }; + return request; +} + +/** + * @beta + * Interface that allows the processing of the proposal-request + * + * @interface ICredentialProposalHandler + */ +export interface ICredentialProposalHandler { + /** + * @beta + * unpacks proposal-request + * @param {Uint8Array} request - raw byte message + * @returns `Promise` + */ + parseProposalRequest(request: Uint8Array): Promise; + + /** + * @beta + * handle proposal-request + * @param {Uint8Array} request - raw byte message + * @param {ProposalRequestHandlerOptions} opts - handler options + * @returns {Promise}` - proposal response message + */ + handleProposalRequest( + request: Uint8Array, + opts?: ProposalRequestHandlerOptions + ): Promise; + + /** + * @beta + * handle proposal protocol message + * @param {ProposalMessage} proposal - proposal message + * @param {ProposalHandlerOptions} opts - options + * @returns `Promise<{ + proposal: ProposalMessage; + }>` + */ + handleProposal( + proposal: ProposalMessage, + opts?: ProposalHandlerOptions + ): Promise<{ + proposal: ProposalMessage; + }>; +} + +/** @beta ProposalRequestHandlerOptions represents proposal-request handler options */ +export type ProposalRequestHandlerOptions = object; + +/** @beta ProposalHandlerOptions represents proposal handler options */ +export type ProposalHandlerOptions = { + proposalRequest?: ProposalRequestMessage; +}; + +/** @beta CredentialProposalHandlerParams represents credential proposal handler params */ +export type CredentialProposalHandlerParams = { + agentUrl: string; + proposalResolverFn: (context: string, type: string) => Promise; + packerParams: PackerParams; +}; + +/** + * + * Allows to process ProposalRequest protocol message + * @beta + * @class CredentialProposalHandler + * @implements implements ICredentialProposalHandler interface + */ +export class CredentialProposalHandler implements ICredentialProposalHandler { + /** + * @beta Creates an instance of CredentialProposalHandler. + * @param {IPackageManager} _packerMgr - package manager to unpack message envelope + * @param {IIdentityWallet} _identityWallet - identity wallet + * @param {CredentialProposalHandlerParams} _params - credential proposal handler params + * + */ + + constructor( + private readonly _packerMgr: IPackageManager, + private readonly _identityWallet: IIdentityWallet, + private readonly _params: CredentialProposalHandlerParams + ) {} + + /** + * @inheritdoc ICredentialProposalHandler#parseProposalRequest + */ + async parseProposalRequest(request: Uint8Array): Promise { + const { unpackedMessage: message } = await this._packerMgr.unpack(request); + const proposalRequest = message as unknown as ProposalRequestMessage; + if (message.type !== PROTOCOL_MESSAGE_TYPE.PROPOSAL_REQUEST_MESSAGE_TYPE) { + throw new Error('Invalid media type'); + } + return proposalRequest; + } + + /** + * @inheritdoc ICredentialProposalHandler#handleProposalRequest + */ + async handleProposalRequest( + request: Uint8Array, + //eslint-disable-next-line @typescript-eslint/no-unused-vars + opts?: ProposalRequestHandlerOptions + ): Promise { + if ( + this._params.packerParams.mediaType === MediaType.SignedMessage && + !this._params.packerParams.packerOptions + ) { + throw new Error(`jws packer options are required for ${MediaType.SignedMessage}`); + } + + const proposalRequest = await this.parseProposalRequest(request); + + if (!proposalRequest.to) { + throw new Error(`failed request. empty 'to' field`); + } + + if (!proposalRequest.from) { + throw new Error(`failed request. empty 'from' field`); + } + + if (!proposalRequest.body?.credentials?.length) { + throw new Error(`failed request. no 'credentials' in body`); + } + + const senderDID = DID.parse(proposalRequest.from); + + let credOfferMessage: CredentialsOfferMessage | undefined = undefined; + let proposalMessage: ProposalMessage | undefined = undefined; + for (let i = 0; i < proposalRequest.body.credentials.length; i++) { + const cred = proposalRequest.body.credentials[i]; + + // check if there is credentials in the wallet + let credsFromWallet: W3CCredential[] = []; + try { + credsFromWallet = await this._identityWallet.findOwnedCredentialsByDID(senderDID, { + type: cred.type, + context: cred.context + }); + } catch (e) { + if ((e as Error).message !== 'no credential satisfied query') { + throw e; + } + } + + if (credsFromWallet.length) { + const guid = uuid.v4(); + if (!credOfferMessage) { + credOfferMessage = { + id: guid, + typ: this._params.packerParams.mediaType, + type: PROTOCOL_MESSAGE_TYPE.CREDENTIAL_OFFER_MESSAGE_TYPE, + thid: proposalRequest.thid ?? guid, + body: { + url: this._params.agentUrl, + credentials: [] + }, + from: proposalRequest.to, + to: proposalRequest.from + }; + } + + credOfferMessage.body.credentials.push( + ...credsFromWallet.map((c) => ({ + id: c.id, + description: '' + })) + ); + continue; + } + + // credential not found in the wallet, prepare proposal protocol message + const proposal = await this._params.proposalResolverFn(cred.context, cred.type); + if (!proposal) { + throw new Error(`can't resolve Proposal for type: ${cred.type}, context: ${cred.context}`); + } + if (!proposalMessage) { + const guid = uuid.v4(); + proposalMessage = { + id: guid, + typ: this._params.packerParams.mediaType, + type: PROTOCOL_MESSAGE_TYPE.PROPOSAL_MESSAGE_TYPE, + thid: proposalRequest.thid ?? guid, + body: { + proposals: [] + }, + from: proposalRequest.to, + to: proposalRequest.from + }; + } + proposalMessage.body?.proposals.push(proposal); + } + + // if there is credentials in the wallet, return offer protocol message, otherwise proposal + const response = byteEncoder.encode(JSON.stringify(proposalMessage ?? credOfferMessage)); + + const packerOpts = + this._params.packerParams.mediaType === MediaType.SignedMessage + ? this._params.packerParams.packerOptions + : { + provingMethodAlg: proving.provingMethodGroth16AuthV2Instance.methodAlg + }; + + return this._packerMgr.pack(this._params.packerParams.mediaType, response, { + senderDID, + ...packerOpts + }); + } + + /** + * @inheritdoc ICredentialProposalHandler#handleProposal + */ + async handleProposal(proposal: ProposalMessage, opts?: ProposalHandlerOptions) { + if (opts?.proposalRequest && opts.proposalRequest.from !== proposal.to) { + throw new Error( + `sender of the request is not a target of response - expected ${opts.proposalRequest.from}, given ${proposal.to}` + ); + } + return { proposal }; + } +} diff --git a/src/iden3comm/handlers/index.ts b/src/iden3comm/handlers/index.ts index 16f4a3c2..5aa2e0d6 100644 --- a/src/iden3comm/handlers/index.ts +++ b/src/iden3comm/handlers/index.ts @@ -4,3 +4,4 @@ export * from './contract-request'; export * from './refresh'; export * from './revocation-status'; export * from './common'; +export * from './credential-proposal'; diff --git a/src/iden3comm/types/index.ts b/src/iden3comm/types/index.ts index 0a4ad565..a45183ee 100644 --- a/src/iden3comm/types/index.ts +++ b/src/iden3comm/types/index.ts @@ -4,6 +4,7 @@ export * from './protocol/messages'; export * from './protocol/proof'; export * from './protocol/revocation'; export * from './protocol/contract-request'; +export * from './protocol/proposal-request'; export * from './packer'; export * from './packageManager'; diff --git a/src/iden3comm/types/protocol/proposal-request.ts b/src/iden3comm/types/protocol/proposal-request.ts new file mode 100644 index 00000000..b6b87d72 --- /dev/null +++ b/src/iden3comm/types/protocol/proposal-request.ts @@ -0,0 +1,38 @@ +import { BasicMessage, JSONObject } from '../'; + +/** @beta ProposalRequestMessage is struct the represents proposal-request message */ +export type ProposalRequestMessage = BasicMessage & { + body?: ProposalRequestMessageBody; +}; + +/** @beta ProposalRequestMessageBody is struct the represents body for proposal-request */ +export type ProposalRequestMessageBody = { + credentials: ProposalRequestCredential[]; + metadata?: { type: string; data?: JSONObject }; + did_doc?: JSONObject; +}; + +/** @beta ProposalMessage is struct the represents proposal message */ +export type ProposalMessage = BasicMessage & { + body?: ProposalMessageBody; +}; + +/** @beta ProposalMessageBody is struct the represents body for proposal message */ +export type ProposalMessageBody = { + proposals: Proposal[]; +}; + +/** @beta ProposalRequestCredential is struct the represents proposal request credential */ +export type ProposalRequestCredential = { + type: string; + context: string; +}; + +/** @beta Proposal is struct the represents proposal inside proposal protocol message */ +export type Proposal = { + credentials?: ProposalRequestCredential[]; + type: string; + url?: string; + expiration?: string; + description?: string; +}; diff --git a/tests/handlers/credential-proposal.test.ts b/tests/handlers/credential-proposal.test.ts new file mode 100644 index 00000000..b4f0ce44 --- /dev/null +++ b/tests/handlers/credential-proposal.test.ts @@ -0,0 +1,241 @@ +import { + IPackageManager, + IdentityWallet, + CredentialWallet, + CredentialStatusResolverRegistry, + RHSResolver, + CredentialStatusType, + FSCircuitStorage, + ProofService, + CircuitId, + byteEncoder, + ICredentialProposalHandler, + CredentialProposalHandler, + createProposalRequest, + createProposal, + CredentialRequest, + ICredentialWallet, + byteDecoder, + CredentialsOfferMessage, + ProposalMessage, + Proposal, + PlainPacker, + PackageManager +} from '../../src'; + +import { + MOCK_STATE_STORAGE, + getInMemoryDataStorage, + getPackageMgr, + registerBJJIntoInMemoryKMS, + createIdentity, + SEED_USER, + SEED_ISSUER, + RHS_URL +} from '../helpers'; + +import { expect } from 'chai'; +import path from 'path'; +import { MediaType, PROTOCOL_MESSAGE_TYPE } from '../../src/iden3comm/constants'; +import { DID } from '@iden3/js-iden3-core'; + +describe('proposal-request handler', () => { + let packageMgr: IPackageManager; + let idWallet: IdentityWallet; + let credWallet: ICredentialWallet; + let proposalRequestHandler: ICredentialProposalHandler; + const agentUrl = 'http://localhost:8001/api/v1/agent'; + let userDID, issuerDID: DID; + const packageManager: IPackageManager = new PackageManager(); + packageManager.registerPackers([new PlainPacker()]); + + const proposalResolverFn = (context: string, type: string): Promise => { + if ( + context === + 'https://raw.githubusercontent.com/iden3/claim-schema-vocab/main/schemas/json-ld/kyc-nonmerklized.jsonld' && + type === 'KYCAgeCredential' + ) { + return Promise.resolve({ + credentials: [ + { + type, + context + } + ], + type: 'WebVerificationForm', + url: 'http://issuer-agent.com/verify?anyUniqueIdentifierOfSession=55', + description: 'you can pass the verification on our KYC provider by following the next link' + }); + } + + throw new Error(`not supported credential, type: ${type}, context: ${context}`); + }; + + beforeEach(async () => { + const kms = registerBJJIntoInMemoryKMS(); + const dataStorage = getInMemoryDataStorage(MOCK_STATE_STORAGE); + const circuitStorage = new FSCircuitStorage({ + dirname: path.join(__dirname, '../proofs/testdata') + }); + const resolvers = new CredentialStatusResolverRegistry(); + resolvers.register( + CredentialStatusType.Iden3ReverseSparseMerkleTreeProof, + new RHSResolver(dataStorage.states) + ); + credWallet = new CredentialWallet(dataStorage, resolvers); + idWallet = new IdentityWallet(kms, dataStorage, credWallet); + + const proofService = new ProofService(idWallet, credWallet, circuitStorage, MOCK_STATE_STORAGE); + packageMgr = await getPackageMgr( + await circuitStorage.loadCircuitData(CircuitId.AuthV2), + proofService.generateAuthV2Inputs.bind(proofService), + proofService.verifyState.bind(proofService) + ); + proposalRequestHandler = new CredentialProposalHandler(packageMgr, idWallet, { + agentUrl, + proposalResolverFn, + packerParams: { + mediaType: MediaType.PlainMessage + } + }); + + const userIdentity = await createIdentity(idWallet, { + seed: SEED_USER + }); + + userDID = userIdentity.did; + + const issuerIdentity = await createIdentity(idWallet, { + seed: SEED_ISSUER + }); + + issuerDID = issuerIdentity.did; + }); + + it('proposal-request handle request with cred exists in wallet (returns credential offer)', async () => { + const claimReq: CredentialRequest = { + credentialSchema: + 'https://raw.githubusercontent.com/iden3/claim-schema-vocab/main/schemas/json/kyc-nonmerklized.json', + type: 'KYCAgeCredential', + credentialSubject: { + id: userDID.string(), + birthday: 19960424, + documentType: 99 + }, + expiration: 2793526400, + revocationOpts: { + type: CredentialStatusType.Iden3ReverseSparseMerkleTreeProof, + id: RHS_URL + } + }; + const issuerCred = await idWallet.issueCredential(issuerDID, claimReq); + + await credWallet.save(issuerCred); + + const proposalRequest = createProposalRequest(userDID, issuerDID, { + credentials: [ + { + type: 'KYCAgeCredential', + context: + 'https://raw.githubusercontent.com/iden3/claim-schema-vocab/main/schemas/json-ld/kyc-nonmerklized.jsonld' + } + ] + }); + + const msgBytesRequest = await packageManager.pack( + MediaType.PlainMessage, + byteEncoder.encode(JSON.stringify(proposalRequest)), + {} + ); + + const response = await proposalRequestHandler.handleProposalRequest(msgBytesRequest); + expect(response).not.to.be.undefined; + const credentialOffer = JSON.parse(byteDecoder.decode(response)) as CredentialsOfferMessage; + expect(credentialOffer.type).to.be.eq(PROTOCOL_MESSAGE_TYPE.CREDENTIAL_OFFER_MESSAGE_TYPE); + expect(credentialOffer.body.credentials.length).to.be.eq(1); + expect(credentialOffer.body.credentials[0].id).to.be.eq(issuerCred.id); + }); + + it('proposal-request handle request with cred NOT exists in wallet (returns proposal message)', async () => { + const proposalRequest = createProposalRequest(userDID, issuerDID, { + credentials: [ + { + type: 'KYCAgeCredential', + context: + 'https://raw.githubusercontent.com/iden3/claim-schema-vocab/main/schemas/json-ld/kyc-nonmerklized.jsonld' + } + ] + }); + + const msgBytesRequest = await packageManager.pack( + MediaType.PlainMessage, + byteEncoder.encode(JSON.stringify(proposalRequest)), + {} + ); + + const response = await proposalRequestHandler.handleProposalRequest(msgBytesRequest); + expect(response).not.to.be.undefined; + const credentialOffer = JSON.parse(byteDecoder.decode(response)) as ProposalMessage; + expect(credentialOffer.type).to.be.eq(PROTOCOL_MESSAGE_TYPE.PROPOSAL_MESSAGE_TYPE); + expect(credentialOffer.body?.proposals.length).to.be.eq(1); + expect(credentialOffer.body?.proposals[0].type).to.be.eq('WebVerificationForm'); + expect(credentialOffer.body?.proposals[0].url).to.be.eq( + 'http://issuer-agent.com/verify?anyUniqueIdentifierOfSession=55' + ); + }); + + it('proposal-request handle not supported credential type in the request', async () => { + const proposalRequest = createProposalRequest(userDID, issuerDID, { + credentials: [ + { + type: 'KYCAgeCredential', + context: + 'https://raw.githubusercontent.com/iden3/claim-schema-vocab/main/schemas/json-ld/kyc-nonmerklized.jsonld' + }, + { + type: 'AnimaProofOfLife', + context: + 'https://raw.githubusercontent.com/anima-protocol/claims-polygonid/main/schemas/json-ld/pol-v1.json-ld' + } + ] + }); + + const msgBytesRequest = await packageManager.pack( + MediaType.PlainMessage, + byteEncoder.encode(JSON.stringify(proposalRequest)), + {} + ); + + try { + await proposalRequestHandler.handleProposalRequest(msgBytesRequest); + expect.fail(); + } catch (err: unknown) { + expect((err as Error).message).to.be.eq( + `not supported credential, type: AnimaProofOfLife, context: https://raw.githubusercontent.com/anima-protocol/claims-polygonid/main/schemas/json-ld/pol-v1.json-ld` + ); + } + }); + + it('proposal-request handle response: wrong sender', async () => { + const proposalRequest = createProposalRequest(userDID, issuerDID, { + credentials: [{ type: 'KycAgeCredential', context: 'https://test.com' }] + }); + + const proposalMessage = createProposal(issuerDID, issuerDID, [ + { + type: 'WebVerificationForm', + url: 'http://issuer-agent.com/verify?anyUniqueIdentifierOfSession=55', + description: 'you can pass the verification on our KYC provider by following the next link' + } + ]); + + try { + await proposalRequestHandler.handleProposal(proposalMessage, { proposalRequest }); + expect.fail(); + } catch (err: unknown) { + expect((err as Error).message).to.include( + `sender of the request is not a target of response` + ); + } + }); +});