diff --git a/examples/cactus-example-discounted-asset-trade/business-logic-asset-trade.ts b/examples/cactus-example-discounted-asset-trade/business-logic-asset-trade.ts index aa2611643e..92e029dee6 100644 --- a/examples/cactus-example-discounted-asset-trade/business-logic-asset-trade.ts +++ b/examples/cactus-example-discounted-asset-trade/business-logic-asset-trade.ts @@ -451,22 +451,17 @@ export class BusinessLogicAssetTrade extends BusinessLogicBase { transactionData, ); - // NOTE: Convert properties to binary. - // If you do not convert the following, you will get an error. - result.data["signedCommitProposal"].signature = Buffer.from( - result.data["signedCommitProposal"].signature, - ); - result.data["signedCommitProposal"].proposal_bytes = Buffer.from( - result.data["signedCommitProposal"].proposal_bytes, - ); - - // Set Parameter - //logger.debug('secondTransaction data : ' + JSON.stringify(result.data)); - const contract = { channelName: "mychannel" }; - const method = { type: "sendSignedTransaction" }; - const args = { args: [result.data] }; + // Call sendSignedTransactionV2 + const contract = { + channelName: config.assetTradeInfo.fabric.channelName, + }; + const method = { type: "function", command: "sendSignedTransactionV2" }; + const args = { + args: result.signedTxArgs, + }; // Run Verifier (Fabric) + logger.debug("Sending fabric.sendSignedTransactionV2"); verifierFabric .sendAsyncRequest(contract, method, args) .then(() => { diff --git a/examples/cactus-example-discounted-asset-trade/sign-utils.ts b/examples/cactus-example-discounted-asset-trade/sign-utils.ts new file mode 100644 index 0000000000..699884393f --- /dev/null +++ b/examples/cactus-example-discounted-asset-trade/sign-utils.ts @@ -0,0 +1,105 @@ +/* + * Copyright 2022 Hyperledger Cactus Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Util tools used for cryptography related to hyperledger fabric (e.g. signing proposals) + */ + +const hash = require("fabric-client/lib/hash"); +import jsrsa from "jsrsasign"; +import elliptic from "elliptic"; + +const ellipticCurves = elliptic.curves as any; + +/** + * This function comes from `CryptoSuite_ECDSA_AES.js` and will be part of the + * stand alone fabric-sig package in future. + * + */ +const ordersForCurve: Record = { + secp256r1: { + halfOrder: ellipticCurves.p256.n.shrn(1), + order: ellipticCurves.p256.n, + }, + secp384r1: { + halfOrder: ellipticCurves.p384.n.shrn(1), + order: ellipticCurves.p384.n, + }, +}; + +/** + * This function comes from `CryptoSuite_ECDSA_AES.js` and will be part of the + * stand alone fabric-sig package in future. + * + * @param sig EC signature + * @param curveParams EC key params. + * @returns Signature + */ +function preventMalleability(sig: any, curveParams: { name: string }) { + const halfOrder = ordersForCurve[curveParams.name].halfOrder; + if (!halfOrder) { + throw new Error( + 'Can not find the half order needed to calculate "s" value for immalleable signatures. Unsupported curve name: ' + + curveParams.name, + ); + } + + // in order to guarantee 's' falls in the lower range of the order, as explained in the above link, + // first see if 's' is larger than half of the order, if so, it needs to be specially treated + if (sig.s.cmp(halfOrder) === 1) { + // module 'bn.js', file lib/bn.js, method cmp() + // convert from BigInteger used by jsrsasign Key objects and bn.js used by elliptic Signature objects + const bigNum = ordersForCurve[curveParams.name].order; + sig.s = bigNum.sub(sig.s); + } + + return sig; +} + +/** + * Internal function to sign input buffer with private key. + * + * @param privateKey private key in PEM format. + * @param proposalBytes Buffer of the proposal to sign. + * @param algorithm Hash function algorithm + * @param keySize Key length + * @returns + */ +function sign( + privateKey: string, + proposalBytes: Buffer, + algorithm: string, + keySize: number, +) { + const hashAlgorithm = algorithm.toUpperCase(); + const hashFunction = hash[`${hashAlgorithm}_${keySize}`]; + const ecdsaCurve = ellipticCurves[`p${keySize}`]; + const ecdsa = new elliptic.ec(ecdsaCurve); + const key = jsrsa.KEYUTIL.getKey(privateKey) as any; + + const signKey = ecdsa.keyFromPrivate(key.prvKeyHex, "hex"); + const digest = hashFunction(proposalBytes); + + let sig = ecdsa.sign(Buffer.from(digest, "hex"), signKey); + sig = preventMalleability(sig, key.ecparams); + + return Buffer.from(sig.toDER()); +} + +/** + * Sign proposal of endorsment / transaction with private key. + * Can be used to call low-level fabric sdk functions. + * + * @param proposalBytes Buffer of the proposal to sign. + * @param paramPrivateKeyPem Private key in PEM format. + * @returns Signed proposal. + */ +export function signProposal( + proposalBytes: Buffer, + paramPrivateKeyPem: string, +) { + return { + signature: sign(paramPrivateKeyPem, proposalBytes, "sha2", 256), + proposal_bytes: proposalBytes, + }; +} diff --git a/examples/cactus-example-discounted-asset-trade/transaction-fabric.ts b/examples/cactus-example-discounted-asset-trade/transaction-fabric.ts index 49ee9f2338..9f82e65b0d 100644 --- a/examples/cactus-example-discounted-asset-trade/transaction-fabric.ts +++ b/examples/cactus-example-discounted-asset-trade/transaction-fabric.ts @@ -17,6 +17,7 @@ import { ConfigUtil } from "@hyperledger/cactus-cmd-socketio-server"; import { Verifier } from "@hyperledger/cactus-verifier-client"; +import { signProposal } from "./sign-utils"; import { FileSystemWallet } from "fabric-network"; @@ -26,21 +27,11 @@ const moduleName = "TransactionFabric"; const logger = getLogger(`${moduleName}`); logger.level = config.logLevel; -interface SendSyncRequestResult { - data: { - signedCommitProposal: { - signature: string | Buffer; - proposal_bytes: string | Buffer; - }; - }; - txId: string; -} - export function makeSignedProposal( ccFncName: string, ccArgs: string[], verifierFabric: Verifier, -): Promise { +): Promise<{ signedTxArgs: unknown; txId: string }> { return new Promise(async (resolve, reject) => { try { /* @@ -70,42 +61,110 @@ export function makeSignedProposal( privateKeyPem = (submitterIdentity as any).privateKey; } - const contract = { + if (!certPem || !privateKeyPem) { + throw new Error( + "Could not read certPem or privateKeyPem from BLP fabric wallet.", + ); + } + + // Get unsigned proposal + const contractUnsignedProp = { channelName: config.assetTradeInfo.fabric.channelName, }; - const method = { type: "function", command: "sendSignedProposal" }; - const argsParam: { + const methodUnsignedProp = { + type: "function", + command: "generateUnsignedProposal", + }; + const argsUnsignedProp = { args: { - transactionProposalReq: Record; - certPem: undefined; - privateKeyPem: undefined; - }; - } = { + transactionProposalReq, + certPem, + }, + }; + + logger.debug("Sending fabric.generateUnsignedProposal"); + const responseUnsignedProp = await verifierFabric.sendSyncRequest( + contractUnsignedProp, + methodUnsignedProp, + argsUnsignedProp, + ); + const proposalBuffer = Buffer.from( + responseUnsignedProp.data.proposalBuffer, + ); + const proposal = responseUnsignedProp.data.proposal; + const txId = responseUnsignedProp.data.txId; + + // Prepare signed proposal + const signedProposal = signProposal(proposalBuffer, privateKeyPem); + + // Call sendSignedProposalV2 + const contractSignedProposal = { + channelName: config.assetTradeInfo.fabric.channelName, + }; + const methodSignedProposal = { + type: "function", + command: "sendSignedProposalV2", + }; + const argsSignedProposal = { args: { - transactionProposalReq: transactionProposalReq, - certPem: certPem, - privateKeyPem: privateKeyPem, + signedProposal, }, }; - verifierFabric - .sendSyncRequest(contract, method, argsParam) - .then((resp) => { - logger.debug(`Successfully build endorse and commit`); - - const args = { - signedCommitProposal: resp.data["signedCommitProposal"], - commitReq: resp.data["commitReq"], - }; - const result: SendSyncRequestResult = { - data: args, - txId: resp.data["txId"], - }; - return resolve(result); - }) - .catch((err) => { - logger.error(`##makeSignedProposal: err: ${err}`); - reject(err); - }); + + logger.debug("Sending fabric.sendSignedProposalV2"); + const responseSignedEndorse = await verifierFabric.sendSyncRequest( + contractSignedProposal, + methodSignedProposal, + argsSignedProposal, + ); + + if (!responseSignedEndorse.data.endorsmentStatus) { + throw new Error("Fabric TX endorsment was not OK."); + } + const proposalResponses = responseSignedEndorse.data.proposalResponses; + + // Get unsigned commit (transaction) proposal + const contractUnsignedTx = { + channelName: config.assetTradeInfo.fabric.channelName, + }; + const methodUnsignedTx = { + type: "function", + command: "generateUnsignedTransaction", + }; + const argsUnsignedTx = { + args: { + proposal: proposal, + proposalResponses: proposalResponses, + }, + }; + + logger.debug("Sending fabric.generateUnsignedTransaction"); + const responseUnsignedTx = await verifierFabric.sendSyncRequest( + contractUnsignedTx, + methodUnsignedTx, + argsUnsignedTx, + ); + + const commitProposalBuffer = Buffer.from( + responseUnsignedTx.data.txProposalBuffer, + ); + + // Prepare signed commit proposal + const signedCommitProposal = signProposal( + commitProposalBuffer, + privateKeyPem, + ); + + const signedTxArgs = { + signedCommitProposal, + proposal, + proposalResponses, + }; + + return resolve({ + txId, + signedTxArgs, + }); } catch (e) { logger.error(`error at Invoke: err=${e}`); return reject(e); diff --git a/packages/cactus-plugin-ledger-connector-fabric-socketio/package.json b/packages/cactus-plugin-ledger-connector-fabric-socketio/package.json index 5265b1a911..8de0aaa893 100644 --- a/packages/cactus-plugin-ledger-connector-fabric-socketio/package.json +++ b/packages/cactus-plugin-ledger-connector-fabric-socketio/package.json @@ -2,9 +2,9 @@ "name": "@hyperledger/cactus-plugin-ledger-connector-fabric-socketio", "version": "1.1.2", "license": "Apache-2.0", - "main": "dist/common/core/bin/www.js", - "module": "dist/common/core/bin/www.js", - "types": "dist/common/core/bin/www.d.ts", + "main": "dist/index.js", + "module": "dist/index.js", + "types": "dist/index.d.ts", "scripts": { "start": "cd ./dist && node common/core/bin/www.js", "debug": "nodemon --inspect ./dist/common/core/bin/www.js", @@ -25,9 +25,12 @@ "fabric-network": "1.4.19", "fast-safe-stringify": "2.1.1", "fs-extra": "10.0.0", + "protobufjs": "5.0.3", + "grpc": "1.24.11", "js-yaml": "3.14.1", "jsonwebtoken": "8.5.1", "log4js": "6.4.1", + "lodash": "4.17.21", "morgan": "1.10.0", "serve-favicon": "2.4.5", "shelljs": "0.8.5", diff --git a/packages/cactus-plugin-ledger-connector-fabric-socketio/src/main/typescript/connector/ServerPlugin.ts b/packages/cactus-plugin-ledger-connector-fabric-socketio/src/main/typescript/connector/ServerPlugin.ts index 2290d6650a..15c0ebe62c 100644 --- a/packages/cactus-plugin-ledger-connector-fabric-socketio/src/main/typescript/connector/ServerPlugin.ts +++ b/packages/cactus-plugin-ledger-connector-fabric-socketio/src/main/typescript/connector/ServerPlugin.ts @@ -11,44 +11,125 @@ * Define and implement the function independently according to the connection destination dependent part (adapter) on the core side. */ -// config file -import { configRead, signMessageJwt } from "@hyperledger/cactus-cmd-socketio-server"; +import path from "path"; +import safeStringify from "fast-safe-stringify"; +import Client, { + Proposal, + ProposalRequest, + ProposalResponse, + Block, +} from "fabric-client"; +import { FileSystemWallet, Gateway } from "fabric-network"; + +import { getClientAndChannel, getSubmitterAndEnroll } from "./fabricaccess"; +import { signProposal } from "./sign-utils"; +import { + ProposalSerializer, + ProposalResponseSerializer, +} from "./fabric-proto-serializers"; + +// Config reading +import { + configRead, + signMessageJwt, +} from "@hyperledger/cactus-cmd-socketio-server"; +const connUserName = configRead("fabric.connUserName"); // Log settings import { getLogger } from "log4js"; const logger = getLogger("ServerPlugin[" + process.pid + "]"); logger.level = configRead("logLevel", "info"); -// Read the library, SDK, etc. according to EC specifications as needed -import { getClientAndChannel, getSubmitterAndEnroll } from "./fabricaccess"; -import Client, { ProposalRequest, Block } from "fabric-client"; -import safeStringify from "fast-safe-stringify"; +///////////////////////////// +// API call signatures +///////////////////////////// -const path = require("path"); -const { FileSystemWallet, Gateway } = require("fabric-network"); -const connUserName = configRead("fabric.connUserName"); +/** + * `generateUnsignedProposal()` input argument type. + */ +type GenerateUnsignedProposalArgs = { + contract: { + channelName: string; + }; + args: { + args: { + transactionProposalReq: Client.ProposalRequest; + certPem: string; + }; + }; + reqID?: string; +}; + +/** + * `generateUnsignedTransaction()` input argument type. + */ +type GenerateUnsignedTransactionArgs = { + contract: { + channelName: string; + }; + args: { + args: { + proposal: string; + proposalResponses: string[]; + }; + }; + reqID?: string; +}; -// Cryptographic for fabric -const hash = require("fabric-client/lib/hash"); -const jsrsa = require("jsrsasign"); -const { KEYUTIL } = jsrsa; -const elliptic = require("elliptic"); -const EC = elliptic.ec; +/** + * `sendSignedProposal()` input argument type. + */ +type SendSignedProposalArgs = { + contract: { + channelName: string; + }; + args: { + args: { + transactionProposalReq: Client.ProposalRequest; + certPem?: string; + privateKeyPem?: string; + }; + }; + reqID?: string; +}; -//let xChannel = undefined; // Channel +/** + * `sendSignedProposalV2()` input argument type. + */ +type SendSignedProposalV2Args = { + contract: { + channelName: string; + }; + args: { + args: { + signedProposal: Buffer; + }; + }; + reqID?: string; +}; -/* - * ServerPlugin - * ServerPlugin class definition +/** + * `sendSignedTransactionV2()` input argument type. */ -export class ServerPlugin { - /* - * constructor - */ - constructor() { - // Define settings specific to the dependent part - } +type SendSignedTransactionV2Args = { + contract: { + channelName: string; + }; + args: { + args: { + signedCommitProposal: Buffer; + proposal: string; + proposalResponses: string[]; + }; + }; + reqID?: string; +}; + +///////////////////////////// +// ServerPlugin Class +///////////////////////////// +export class ServerPlugin { /* * isExistFunction * @@ -232,76 +313,168 @@ export class ServerPlugin { } /** - * sendSignedProposal with commit. - * @param {object} args : JSON Object - * { - * "args": { - * "contract": {"channelName": channelName}, - * "args":[ - * { - * "transactionProposalReq":, - * "certPem"?:, - * "privateKeyPem"?: - * } - * ] - * }, - * "reqID": // option - * } - * @return {Object} JSON object + * API request to send commit transaction signed on the client side. + * No user cryptographic data is handled by this function. + * Uses Fabric-SDK `channel.sendSignedTransaction` call. + * + * @param args.signedCommitProposal Signed commit proposal buffer. + * @param args.proposal Encoded proposal from `generateUnsignedProposal` API call. + * @param args.proposalResponses Encoded endorsing responses from `sendSignedProposalV2` API call. + * @returns Send status. */ - sendSignedProposal(args: any) { - return new Promise((resolve, reject) => { - logger.info("sendSignedProposal start"); - let retObj: Record; + async sendSignedTransactionV2(args: SendSignedTransactionV2Args) { + logger.info("sendSignedTransactionV2 start"); - const channelName = args.contract.channelName; - const transactionProposalReq = args.args.args.transactionProposalReq; - const certPem = args.args.args.certPem; - const privateKeyPem = args.args.args.privateKeyPem; - let reqID = args["reqID"]; - if (reqID === undefined) { - reqID = null; + // Parse arguments + const channelName = args.contract.channelName; + const signedCommitProposal = args.args.args.signedCommitProposal; + const proposal = ProposalSerializer.decode(args.args.args.proposal); + let proposalResponses: any[] = args.args.args.proposalResponses.map((val) => + ProposalResponseSerializer.decode(val), + ); + let reqID = args.reqID; + logger.info(`##sendSignedTransactionV2: reqID: ${reqID}`); + + if ( + !channelName || + !signedCommitProposal || + !proposal || + proposalResponses.length === 0 + ) { + throw { + resObj: { + status: 504, + errorDetail: "sendSignedTransactionV2: Invalid input parameters", + }, + }; + } + + // Logic + try { + const invokeResponse = await InvokeSendSignedTransaction({ + signedCommitProposal, + commitReq: { + proposal, + proposalResponses, + }, + channelName: channelName, + }); + logger.info("sendSignedTransactionV2: done."); + + return { + id: reqID, + resObj: { + status: 200, + data: invokeResponse, + }, + }; + } catch (error) { + logger.error("sendSignedTransactionV2() error:", error); + throw { + resObj: { + status: 504, + errorDetail: safeStringify(error), + }, + }; + } + } + + /** + * API request to send transaction endorsment. + * Uses cryptographic data either from input arguments, or from local (connectors) wallet. + * + * @param args.transactionProposalReq Raw transaction that will be turned into proposal. + * @param args.certPem Client public key in PEM format. + * @param args.privateKeyPem Client private key in PEM format. + * @returns signedCommitProposal Signed transaction proposal. + * @returns commitReq Unsigned commit request. + * @returns txId Transaction ID. + */ + async sendSignedProposal(args: SendSignedProposalArgs) { + logger.info("sendSignedProposal start"); + + // Parse arguments + const channelName = args.contract.channelName; + const transactionProposalReq = args.args.args.transactionProposalReq; + let certPem = args.args.args.certPem; + let privateKeyPem = args.args.args.privateKeyPem; + let reqID = args.reqID; + logger.info(`##sendSignedProposal: reqID: ${reqID}`); + + // Logic + try { + let { client, channel } = await getClientAndChannel(channelName); + + if (!certPem || !privateKeyPem) { + // Get identity from connector wallet + const submiterId = await getSubmiterIdentityCrypto(client); + certPem = submiterId.certPem; + privateKeyPem = submiterId.privateKeyPem; } - logger.info(`##sendSignedProposal: reqID: ${reqID}`); - // call chainncode - InvokeSendSignedProposal( - channelName, + if (!certPem || !privateKeyPem) { + throw Error( + "Could not read certificate and private key of the submitter.", + ); + } + + // Generate endorsement proposal + const { proposal, txId } = InvokeGenerateUnsignedProposal( + channel, transactionProposalReq, certPem, + ); + const signedProposal = signProposal(proposal.toBuffer(), privateKeyPem); + + // Send proposal, get endorsment responses + const { + endorsmentStatus, + proposalResponses, + } = await InvokeSendSignedProposalV2(channel, signedProposal as any); + logger.info("sendSignedProposal: done."); + + if (!endorsmentStatus) { + throw new Error( + "Failed to send Proposal or receive valid response. Response null or status is not 200. exiting...", + ); + } + + // Generate commit proposal + const commitProposal = await InvokeGenerateUnsignedTransaction( + channel, + proposalResponses as any, + proposal, + ); + const signedCommitProposal = signProposal( + commitProposal.toBuffer(), privateKeyPem, - ) - .then((signedTx) => { - if (signedTx != null) { - const signedResults = signMessageJwt({ - result: signedTx, - }); - retObj = { - resObj: { - status: 200, - // "data": signedTx - data: signedResults, - }, - }; - if (reqID !== null) { - retObj["id"] = reqID; - } - logger.info(`sendSignedProposal resolve`); - return resolve(retObj); - } - }) - .catch((err) => { - retObj = { - resObj: { - status: 504, - errorDetail: safeStringify(err), - }, - }; - logger.error(err); - logger.info(`sendSignedProposal reject`); - return reject(retObj); - }); - }); + ); + + // Send the response + const signedResults = signMessageJwt({ + result: { + signedCommitProposal, + commitReq: { proposalResponses, proposal }, + txId: txId.getTransactionID(), + }, + }); + + return { + id: reqID, + resObj: { + status: 200, + data: signedResults, + }, + }; + } catch (error) { + logger.error("sendSignedProposal() error:", error); + throw { + resObj: { + status: 504, + errorDetail: safeStringify(error), + }, + }; + } } /** @@ -402,71 +575,219 @@ export class ServerPlugin { return retObj; } -} /* class */ -/* - * Invoke function - * @param reqBody [json object] {fcn:, args:[arg1>,,,,]} - * @return [string] Success: Chain code execution result - * Failure: Chain code error or internal error - */ -async function Invoke(reqBody: any) { - let txId = null; - const theUser = null; - const eventhubs = []; - //var invokeResponse; //Return value from chain code - - try { - logger.info("##fablicaccess: Invoke start"); - - const fcn = reqBody.fcn; - const args = reqBody.args; - - // Create a new file system based wallet for managing identities. - const wallet = new FileSystemWallet(configRead("fabric.keystore")); - console.log(`Wallet path: ${configRead("fabric.keystore")}`); - - // Check to see if we've already enrolled the user. - const userExists = await wallet.exists(connUserName); - if (!userExists) { - //logger.error(`An identity for the user ${connUserName} does not exist in the wallet`); - const errMsg = `An identity for the user ${connUserName} does not exist in the wallet`; - logger.error(errMsg); - logger.error("Run the registerUser.js application before retrying"); + /** + * API request to send endorsement proposal signed on the client side. + * No user cryptographic data is handled by this function. + * Uses Fabric-SDK `channel.sendSignedProposal` call. + * + * @param args.signedProposal Signed proposal buffer from `generateUnsignedProposal` API call. + * @returns endorsmentStatus Bool whether endorsment was OK or not. + * @returns proposalResponses Encoded responses to be used to generate commit proposal. + */ + async sendSignedProposalV2(args: SendSignedProposalV2Args) { + logger.info("sendSignedProposalV2 start"); + + // Parse arguments + const channelName = args.contract.channelName; + const signedProposal = args.args.args.signedProposal; + let reqID = args.reqID; + logger.info(`##sendSignedProposalV2: reqID: ${reqID}`); + + // Logic + try { + let { channel } = await getClientAndChannel(channelName); + + const invokeResponse = await InvokeSendSignedProposalV2( + channel, + signedProposal, + ); + logger.info("sendSignedProposalV2: done."); + + let proposalResponses = invokeResponse.proposalResponses.map((val: any) => + ProposalResponseSerializer.encode(val), + ); + logger.debug( + `sendSignedProposalV2: encoded ${proposalResponses.length} proposalResponses.`, + ); + + const signedResults = signMessageJwt({ + result: { + endorsmentStatus: invokeResponse.endorsmentStatus, + proposalResponses, + }, + }); + + return { + id: reqID, + resObj: { + status: 200, + data: signedResults, + }, + }; + } catch (error) { + logger.error("sendSignedProposalV2() error:", error); + throw { + resObj: { + status: 504, + errorDetail: safeStringify(error), + }, + }; } + } - // Create a new gateway for connecting to our peer node. - let { client } = await getClientAndChannel(reqBody.channelName); - await getSubmitterAndEnroll(client); + /** + * API request to generate unsigned endorse proposal. + * Proposal must be signed on the client side. + * Uses Fabric-SDK `channel.generateUnsignedProposal` call. + * + * @param args.transactionProposalReq Raw transaction that will be turned into proposal. + * @param args.certPem Client public key in PEM format. + * @returns proposalBuffer Generated unsigned endorse proposal buffer. + * @returns proposal Encoded proposal to be used in follow-up calls to the connector. + * @returns txId Transaction ID. + */ + async generateUnsignedProposal(args: GenerateUnsignedProposalArgs) { + logger.info("generateUnsignedProposal: start"); - const gateway = new Gateway(); - await gateway.connect(client, { - wallet, - identity: connUserName, - discovery: { enabled: false }, - }); + // Parse arguments + const channelName = args.contract.channelName; + const transactionProposalReq = args.args.args.transactionProposalReq; + const certPem = args.args.args.certPem; + let reqID = args.reqID; + logger.info(`##generateUnsignedProposal: reqID: ${reqID}`); + + // Logic + try { + let { channel } = await getClientAndChannel(channelName); + + let invokeResponse = InvokeGenerateUnsignedProposal( + channel, + transactionProposalReq, + certPem, + ); + if (!invokeResponse.proposal || !invokeResponse.txId) { + throw new Error( + "generateUnsignedProposal: empty proposal or transaction id.", + ); + } + logger.info(`generateUnsignedProposal: done.`); + + const signedResults = signMessageJwt({ + result: { + proposalBuffer: invokeResponse.proposal.toBuffer(), + proposal: ProposalSerializer.encode(invokeResponse.proposal), + txId: invokeResponse.txId.getTransactionID(), + }, + }); - // Get the network (channel) our contract is deployed to. - const network = await gateway.getNetwork(reqBody.channelName); + return { + id: reqID, + resObj: { + status: 200, + data: signedResults, + }, + }; + } catch (error) { + logger.error("generateUnsignedProposal() error:", error); + throw { + resObj: { + status: 504, + errorDetail: safeStringify(error), + }, + }; + } + } - // Get the contract from the network. - const contract = network.getContract(reqBody.contractName); + /** + * API request to generate unsigned commit (transaction) proposal. + * Proposal must be signed on the client side. + * Uses Fabric-SDK `channel.generateUnsignedTransaction` call. + * + * @param args.proposal Encoded proposal from `generateUnsignedProposal` API call. + * @param args.proposalResponses Encoded proposal responses from `sendSignedProposalV2` API call. + * @returns txProposalBuffer Unsigned proposal buffer. + */ + async generateUnsignedTransaction(args: GenerateUnsignedTransactionArgs) { + logger.info("generateUnsignedTransaction: start"); - // Submit the specified transaction. - logger.info( - `##fablicaccess: Invoke Params: fcn=${fcn}, args0=${args[0]}, args1=${args[1]}`, + // Parse arguments + const channelName = args.contract.channelName; + const proposal = ProposalSerializer.decode(args.args.args.proposal); + let proposalResponses: any[] = args.args.args.proposalResponses.map((val) => + ProposalResponseSerializer.decode(val), + ); + logger.debug( + `##generateUnsignedTransaction Received ${proposalResponses.length} proposal responses`, ); - const transaction = contract.createTransaction(fcn); + let reqID = args.reqID; + logger.info(`##generateUnsignedTransaction: reqID: ${reqID}`); + + // Logic + try { + let { channel } = await getClientAndChannel(channelName); - txId = transaction.getTransactionID().getTransactionID(); - logger.info("##fablicaccess: txId = " + txId); + const txProposal = await InvokeGenerateUnsignedTransaction( + channel, + proposalResponses, + proposal, + ); + logger.info(`generateUnsignedTransaction: done.`); + + const signedResults = signMessageJwt({ + result: { + txProposalBuffer: txProposal.toBuffer(), + }, + }); + + return { + id: reqID, + resObj: { + status: 200, + data: signedResults, + }, + }; + } catch (error) { + logger.error("generateUnsignedTransaction() error:", error); + throw { + resObj: { + status: 504, + errorDetail: safeStringify(error), + }, + }; + } + } +} /* class */ + +///////////////////////////// +// Invoke (logic) functions +///////////////////////////// - const respData = await transaction.submit(args[0], args[1]); +/** + * Read public and private keys of the submitter from connector wallet. + * Throws `Error()` when submiters identity is missing. + * + * @param client Fabric-SDK channel client object. + * @returns `certPem`, `privateKeyPem` + */ +async function getSubmiterIdentityCrypto(client: Client) { + const wallet = new FileSystemWallet(configRead("fabric.keystore")); + logger.debug( + `Wallet path: ${path.resolve(configRead("fabric.keystore"))}`, + ); - // const respData = await contract.submitTransaction(fcn, args[0], args[1]); - logger.info("Transaction has been submitted"); - } catch (error) { - logger.error(`Failed to submit transaction: ${error}`); + let user = await getSubmitterAndEnroll(client); + const submitterName = user.getName(); + const submitterExists = await wallet.exists(submitterName); + if (submitterExists) { + const submitterIdentity = await wallet.export(submitterName); + const certPem = (submitterIdentity as any).certificate as string; + const privateKeyPem = (submitterIdentity as any).privateKey as string; + return { certPem, privateKeyPem }; + } else { + throw new Error( + `No cert/key provided and submitter ${submitterName} is missing in validator wallet!`, + ); } } @@ -597,80 +918,6 @@ async function InvokeSync(reqBody: any) { }); } -// BEGIN Signature process===================================================================================== -// this ordersForCurve comes from CryptoSuite_ECDSA_AES.js and will be part of the -// stand alone fabric-sig package in future. -const ordersForCurve: Record = { - secp256r1: { - halfOrder: elliptic.curves.p256.n.shrn(1), - order: elliptic.curves.p256.n, - }, - secp384r1: { - halfOrder: elliptic.curves.p384.n.shrn(1), - order: elliptic.curves.p384.n, - }, -}; - -// this function comes from CryptoSuite_ECDSA_AES.js and will be part of the -// stand alone fabric-sig package in future. -function preventMalleability(sig: any, curveParams: { name: string }) { - const halfOrder = ordersForCurve[curveParams.name].halfOrder; - if (!halfOrder) { - throw new Error( - 'Can not find the half order needed to calculate "s" value for immalleable signatures. Unsupported curve name: ' + - curveParams.name, - ); - } - - // in order to guarantee 's' falls in the lower range of the order, as explained in the above link, - // first see if 's' is larger than half of the order, if so, it needs to be specially treated - if (sig.s.cmp(halfOrder) === 1) { - // module 'bn.js', file lib/bn.js, method cmp() - // convert from BigInteger used by jsrsasign Key objects and bn.js used by elliptic Signature objects - const bigNum = ordersForCurve[curveParams.name].order; - sig.s = bigNum.sub(sig.s); - } - - return sig; -} - -/** - * this method is used for test at this moment. In future this - * would be a stand alone package that running at the browser/cellphone/PAD - * - * @param {string} privateKey PEM encoded private key - * @param {Buffer} proposalBytes proposal bytes - */ -function sign( - privateKey: string, - proposalBytes: Buffer, - algorithm: string, - keySize: number, -) { - const hashAlgorithm = algorithm.toUpperCase(); - const hashFunction = hash[`${hashAlgorithm}_${keySize}`]; - const ecdsaCurve = elliptic.curves[`p${keySize}`]; - const ecdsa = new EC(ecdsaCurve); - const key = KEYUTIL.getKey(privateKey); - - const signKey = ecdsa.keyFromPrivate(key.prvKeyHex, "hex"); - const digest = hashFunction(proposalBytes); - - let sig = ecdsa.sign(Buffer.from(digest, "hex"), signKey); - sig = preventMalleability(sig, key.ecparams); - - return Buffer.from(sig.toDER()); -} - -function signProposal(proposalBytes: Buffer, paramPrivateKeyPem: string) { - logger.debug("signProposal start"); - - const signature = sign(paramPrivateKeyPem, proposalBytes, "sha2", 256); - const signedProposal = { signature, proposal_bytes: proposalBytes }; - return signedProposal; -} -// END Signature process========================================================================================= - /** * Function for InvokeSendSignedTransaction * @param reqBody [json object] {signedCommitProposal:, commitReq:, channelName:} @@ -717,56 +964,73 @@ async function InvokeSendSignedTransaction(reqBody: any) { } /** - * Function for InvokeSendSignedProposal - * @param transactionProposalReq [json object] {signedCommitProposal:, commitReq:, channelName:} - * @param certPem? [json object] {signedCommitProposal:, commitReq:, channelName:} - * @param privateKeyPem? [json object] {signedCommitProposal:, commitReq:, channelName:} - * @return [string] signed transaction. + * Call `channel.generateUnsignedProposal` to generate unsigned endorse proposal. + * + * @param channel Fabric-SDK channel object. + * @param txProposal Raw transaction. + * @param certPem Sender public key in PEM format. + * @returns `proposal` - Proposal, `txId` - Transaction object */ -async function InvokeSendSignedProposal( - channelName: string, - transactionProposalReq: ProposalRequest, - certPem?: string, - privateKeyPem?: string, +function InvokeGenerateUnsignedProposal( + channel: Client.Channel, + txProposal: ProposalRequest, + certPem: string, ) { - logger.debug(`InvokeSendSignedProposal start`); - - let invokeResponse2; // Return value from chain code - let { client, channel } = await getClientAndChannel(channelName); - let user = await getSubmitterAndEnroll(client); - - // Low-level access to local-store cert and private key of submitter (in case request is missing those) - if (!certPem || !privateKeyPem) { - const wallet = new FileSystemWallet(configRead("fabric.keystore")); - logger.debug( - `Wallet path: ${path.resolve(configRead("fabric.keystore"))}`, + if (!txProposal || !certPem) { + throw new Error( + "InvokeGenerateUnsignedProposal: Invalid input parameters.", ); - - const submitterName = user.getName(); - const submitterExists = await wallet.exists(submitterName); - if (submitterExists) { - const submitterIdentity = await wallet.export(submitterName); - certPem = (submitterIdentity as any).certificate; - privateKeyPem = (submitterIdentity as any).privateKey; - } else { - throw new Error( - `No cert/key provided and submitter ${submitterName} is missing in validator wallet!`, - ); - } } - if (!certPem || !privateKeyPem) { - throw Error("Could not read certificate and private key of the submitter."); - } + logger.debug("Call channel.generateUnsignedProposal()"); - const { proposal, txId } = channel.generateUnsignedProposal( - transactionProposalReq, + return (channel.generateUnsignedProposal( + txProposal, configRead("fabric.mspid"), certPem, false, - ) as any; - logger.debug(`##InvokeSendSignedProposal; txId: ${txId.getTransactionID()}`); - const signedProposal = signProposal(proposal.toBuffer(), privateKeyPem); + ) as any) as { proposal: any; txId: any }; +} + +/** + * Call `channel.generateUnsignedTransaction` to generate unsigned commit proposal. + * + * @param channel Fabric-SDK channel object. + * @param proposalResponses Proposal responses from endorse step. + * @param proposal Unsigned proposal from `generateUnsignedProposal` + * @returns Unsigned commit proposal. + */ +async function InvokeGenerateUnsignedTransaction( + channel: Client.Channel, + proposalResponses: ProposalResponse[], + proposal: Proposal, +) { + if (!proposal || !proposalResponses || proposalResponses.length === 0) { + throw new Error( + "InvokeGenerateUnsignedTransaction: Invalid input parameters.", + ); + } + + logger.debug("Call channel.generateUnsignedTransaction()"); + + return await channel.generateUnsignedTransaction({ + proposalResponses, + proposal, + }); +} + +/** + * Call `channel.sendSignedProposal`, gather and check the responses. + * + * @param channel Fabric-SDK channel object. + * @param signedProposal Proposal from `generateUnsignedProposal` signed with sender private key. + * @returns `endorsmentStatus`, `proposalResponses` + */ +async function InvokeSendSignedProposalV2( + channel: Client.Channel, + signedProposal: Buffer, +) { + logger.debug(`InvokeSendSignedProposalV2: start`); const targets = []; for (const peerInfo of configRead("fabric.peers")) { @@ -774,77 +1038,29 @@ async function InvokeSendSignedProposal( targets.push(peer); } const sendSignedProposalReq = { signedProposal, targets } as any; + const proposalResponses = await channel.sendSignedProposal( sendSignedProposalReq, ); - logger.debug("##InvokeSendSignedProposal: successfully send signedProposal"); + logger.debug( + "##InvokeSendSignedProposalV2: successfully sent signedProposal", + ); - let allGood = true; + // Determine endorsment status + let endorsmentStatus = true; for (const proposalResponse of proposalResponses) { - let oneGood = false; const propResponse = (proposalResponse as unknown) as Client.ProposalResponse; if ( - propResponse && - propResponse.response && - propResponse.response.status === 200 + !propResponse || + !propResponse.response || + propResponse.response.status !== 200 ) { - if (propResponse.response.payload) { - invokeResponse2 = propResponse.response.payload; - } - oneGood = true; - } else { - logger.debug("##InvokeSendSignedProposal: transaction proposal was bad"); - const resStr = proposalResponse.toString(); - const errMsg = resStr.replace("Error: ", ""); - throw new Error(errMsg); + endorsmentStatus = false; } - allGood = allGood && oneGood; - } - - // If the return value of invoke is an empty string, store txID - if (!invokeResponse2 || invokeResponse2.length == 0) { - invokeResponse2 = txId.getTransactionID(); - } - - // Error if all peers do not return status 200 - if (!allGood) { - const errMsg = - "'Failed to send Proposal or receive valid response. Response null or status is not 200. exiting..."; - logger.debug(`##InvokeSendSignedProposal: ${errMsg}`); - throw new Error(errMsg); } - /** - * End the endorse step. - * Start to commit the tx. - */ - const commitReq = { + return { + endorsmentStatus, proposalResponses, - proposal, - }; - - const commitProposal = await channel.generateUnsignedTransaction( - commitReq as any, - ); - logger.debug( - `##InvokeSendSignedProposal: Successfully build commit transaction proposal`, - ); - - // sign this commit proposal at local - const signedCommitProposal = signProposal( - commitProposal.toBuffer(), - privateKeyPem, - ); - - const signedTx = { - signedCommitProposal: signedCommitProposal, - commitReq: commitReq, - txId: txId.getTransactionID(), }; - - // logger.debug(`##InvokeSendSignedProposal: signature: ${signedCommitProposal.signature}`); - // logger.debug(`##InvokeSendSignedProposal: proposal_bytes: ${signedCommitProposal.proposal_bytes}`); - // logger.debug(`##InvokeSendSignedProposal: signedTx: ${JSON.stringify(signedTx)}`); - logger.debug("##InvokeSendSignedProposal: signedTx:", signedTx); - return signedTx; } diff --git a/packages/cactus-plugin-ledger-connector-fabric-socketio/src/main/typescript/connector/fabric-proto-serializers.ts b/packages/cactus-plugin-ledger-connector-fabric-socketio/src/main/typescript/connector/fabric-proto-serializers.ts new file mode 100644 index 0000000000..bb9e1ae286 --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-fabric-socketio/src/main/typescript/connector/fabric-proto-serializers.ts @@ -0,0 +1,162 @@ +/* + * Copyright 2022 Hyperledger Cactus Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * fabric-proto-serializers.js + * + * Helper utils that are used to serialize and deserialize hyperledger fabric structures + * so they can be safely sent through the wire. Depends heavily on protobuf specifications from + * fabric packages. + */ + +import Client from "fabric-client"; +import { cloneDeep } from "lodash"; + +// TS import is using newer protobufjs version typings, use nodejs import for now. +const protobuf = require("protobufjs"); + +////////////////////////////////// +// Helper Functions +////////////////////////////////// + +/** + * Load protobuf definition from fabric module + * @param protoFilePath module name and relative path to protofile to open + * @returns protofile builder object (response of `protobuf.loadProtoFile()`) + */ +function loadFabricProto(protoFilePath: string) { + const fabricModuleProtoPath = require.resolve(protoFilePath); + return protobuf.loadProtoFile(fabricModuleProtoPath).build(); +} + +/** + * Some Fabric SDK functions does not work well with protobuf decoded object type (Message). + * Also, nested `ByteBuffer` are not unwrapped by top-level decode call, this function will do it manually. + * + * @param messageObject - Message produced by protobuf decode call + * @returns Parsed `messageObject` without any nested `ByteBuffer`, that can be safely used by fabric-sdk methods. + */ +function convertToPlainObject(messageObject: Record) { + let plainObject: Record = {}; + + for (let i in messageObject) { + if (messageObject.hasOwnProperty(i)) { + const currentElem = messageObject[i]; + + if (currentElem instanceof protobuf.ByteBuffer) { + // Convert ByteBuffer fields to nodejs Buffer type. + plainObject[i] = currentElem.toBuffer(); + } else if ( + typeof currentElem === "object" && + !Array.isArray(currentElem) && + currentElem !== null + ) { + // Convert each other nested objects as well. + plainObject[i] = convertToPlainObject(currentElem); + } else { + // Copy scalar fields as is + plainObject[i] = currentElem; + } + } + } + + return plainObject; +} + +/** + * Encode some metadata to the message that will be sent to client app. + * + * @param typeName Name of the type we serialied. + * @param encodedData Serialized object representation. + * @returns JSON with metadata included. + */ +function encodeMetdata(typeName: string, encodedData: string): string { + return JSON.stringify({ + metadata: { + typeName, + }, + encodedData: encodedData, + }); +} + +/** + * Decode the message, read and validate metadata attached to this message. + * + * @param typeName Name of the type wa want to deserialize. + * @param encodedMessage Message received from the client. + * @returns Encoded object without metadata. + */ +function decodeMetdata(typeName: string, encodedMessage: string): string { + const { metadata, encodedData } = JSON.parse(encodedMessage); + + // Check metadata + if (metadata.typeName !== typeName) { + throw new Error( + `decodeMetdata(): requested type mismatch. Wanted to decode: ${typeName}, received: ${metadata.typeName}`, + ); + } + + return encodedData; +} + +////////////////////////////////// +// Serializers +////////////////////////////////// + +// Protobuf builders +const proposalBuilder = loadFabricProto( + "fabric-client/lib/protos/peer/proposal.proto", +); +const proposalResponseBuilder = loadFabricProto( + "fabric-client/lib/protos/peer/proposal_response.proto", +); + +/** + * Client.Proposal serializers + */ +export namespace ProposalSerializer { + export const ProposalType = proposalBuilder.protos.Proposal; + const proposalTypeName = ProposalType["$type"].name; + + export function encode(proposal: Client.Proposal): string { + return encodeMetdata(proposalTypeName, (proposal as any).encodeJSON()); + } + + export function decode(encodedProposal: string): Client.Proposal { + return ProposalType.decodeJSON( + decodeMetdata(proposalTypeName, encodedProposal), + ); + } +} + +/** + * Client.ProposalResponse serializers + */ +export namespace ProposalResponseSerializer { + export const ProposalResponseType = + proposalResponseBuilder.protos.ProposalResponse; + const proposalResponseTypeName = ProposalResponseType["$type"].name; + + export function encode(proposalResponse: Client.ProposalResponse): string { + let proposalResponseCopy = cloneDeep(proposalResponse) as Record< + string, + any + >; + + // Peer is not part of protobuf definition, remove it. + delete proposalResponseCopy.peer; + + let proposalResponseMessage = new ProposalResponseType( + proposalResponseCopy, + ); + const encodedProposalResponse = proposalResponseMessage.encodeJSON(); + return encodeMetdata(proposalResponseTypeName, encodedProposalResponse); + } + + export function decode(encodedProposalResponse: string) { + let decodedProposalResponse = ProposalResponseType.decodeJSON( + decodeMetdata(proposalResponseTypeName, encodedProposalResponse), + ); + return convertToPlainObject(decodedProposalResponse); + } +} diff --git a/packages/cactus-plugin-ledger-connector-fabric-socketio/src/main/typescript/connector/sign-utils.ts b/packages/cactus-plugin-ledger-connector-fabric-socketio/src/main/typescript/connector/sign-utils.ts new file mode 100644 index 0000000000..699884393f --- /dev/null +++ b/packages/cactus-plugin-ledger-connector-fabric-socketio/src/main/typescript/connector/sign-utils.ts @@ -0,0 +1,105 @@ +/* + * Copyright 2022 Hyperledger Cactus Contributors + * SPDX-License-Identifier: Apache-2.0 + * + * Util tools used for cryptography related to hyperledger fabric (e.g. signing proposals) + */ + +const hash = require("fabric-client/lib/hash"); +import jsrsa from "jsrsasign"; +import elliptic from "elliptic"; + +const ellipticCurves = elliptic.curves as any; + +/** + * This function comes from `CryptoSuite_ECDSA_AES.js` and will be part of the + * stand alone fabric-sig package in future. + * + */ +const ordersForCurve: Record = { + secp256r1: { + halfOrder: ellipticCurves.p256.n.shrn(1), + order: ellipticCurves.p256.n, + }, + secp384r1: { + halfOrder: ellipticCurves.p384.n.shrn(1), + order: ellipticCurves.p384.n, + }, +}; + +/** + * This function comes from `CryptoSuite_ECDSA_AES.js` and will be part of the + * stand alone fabric-sig package in future. + * + * @param sig EC signature + * @param curveParams EC key params. + * @returns Signature + */ +function preventMalleability(sig: any, curveParams: { name: string }) { + const halfOrder = ordersForCurve[curveParams.name].halfOrder; + if (!halfOrder) { + throw new Error( + 'Can not find the half order needed to calculate "s" value for immalleable signatures. Unsupported curve name: ' + + curveParams.name, + ); + } + + // in order to guarantee 's' falls in the lower range of the order, as explained in the above link, + // first see if 's' is larger than half of the order, if so, it needs to be specially treated + if (sig.s.cmp(halfOrder) === 1) { + // module 'bn.js', file lib/bn.js, method cmp() + // convert from BigInteger used by jsrsasign Key objects and bn.js used by elliptic Signature objects + const bigNum = ordersForCurve[curveParams.name].order; + sig.s = bigNum.sub(sig.s); + } + + return sig; +} + +/** + * Internal function to sign input buffer with private key. + * + * @param privateKey private key in PEM format. + * @param proposalBytes Buffer of the proposal to sign. + * @param algorithm Hash function algorithm + * @param keySize Key length + * @returns + */ +function sign( + privateKey: string, + proposalBytes: Buffer, + algorithm: string, + keySize: number, +) { + const hashAlgorithm = algorithm.toUpperCase(); + const hashFunction = hash[`${hashAlgorithm}_${keySize}`]; + const ecdsaCurve = ellipticCurves[`p${keySize}`]; + const ecdsa = new elliptic.ec(ecdsaCurve); + const key = jsrsa.KEYUTIL.getKey(privateKey) as any; + + const signKey = ecdsa.keyFromPrivate(key.prvKeyHex, "hex"); + const digest = hashFunction(proposalBytes); + + let sig = ecdsa.sign(Buffer.from(digest, "hex"), signKey); + sig = preventMalleability(sig, key.ecparams); + + return Buffer.from(sig.toDER()); +} + +/** + * Sign proposal of endorsment / transaction with private key. + * Can be used to call low-level fabric sdk functions. + * + * @param proposalBytes Buffer of the proposal to sign. + * @param paramPrivateKeyPem Private key in PEM format. + * @returns Signed proposal. + */ +export function signProposal( + proposalBytes: Buffer, + paramPrivateKeyPem: string, +) { + return { + signature: sign(paramPrivateKeyPem, proposalBytes, "sha2", 256), + proposal_bytes: proposalBytes, + }; +} diff --git a/packages/cactus-plugin-ledger-connector-fabric-socketio/src/main/typescript/public-api.ts b/packages/cactus-plugin-ledger-connector-fabric-socketio/src/main/typescript/public-api.ts index 96f06248b0..291cedabcf 100644 --- a/packages/cactus-plugin-ledger-connector-fabric-socketio/src/main/typescript/public-api.ts +++ b/packages/cactus-plugin-ledger-connector-fabric-socketio/src/main/typescript/public-api.ts @@ -1 +1 @@ -export { startFabricSocketIOConnector } from "./common/core/bin/www" +export { startFabricSocketIOConnector } from "./common/core/bin/www"; diff --git a/packages/cactus-plugin-ledger-connector-fabric-socketio/src/test/typescript/integration/fabric-socketio-connector.test.ts b/packages/cactus-plugin-ledger-connector-fabric-socketio/src/test/typescript/integration/fabric-socketio-connector.test.ts index 9b4d48df97..7a3c184270 100644 --- a/packages/cactus-plugin-ledger-connector-fabric-socketio/src/test/typescript/integration/fabric-socketio-connector.test.ts +++ b/packages/cactus-plugin-ledger-connector-fabric-socketio/src/test/typescript/integration/fabric-socketio-connector.test.ts @@ -2,6 +2,16 @@ * Functional test of basic operations on connector-fabric-socketio (packages/cactus-plugin-ledger-connector-fabric-socketio) * Assumes sample CC was is deployed on the test ledger. * Tests include sending and evaluation transactions, and monitoring for events. + * + * You can speed up development or troubleshooting by using same ledger repeatadely. + * 1. Remove fabric wallets from previous runs - `rm -rf /tmp/fabric-test-wallet*`. Repeat this everytime you restart ledger. + * 2. Change variable `leaveLedgerRunning` to true. + * 3. Run this functional test. It will leave the ledger running, and will enroll the users to common wallet location. + * 4. Change `useRunningLedger` to true. The following test runs will not setup the ledger again. + * Note: + * You may get a warning about open SIGNREQUEST handles after the test finishes. + * These are false-positives, and should be fixed in jest v28.1.0 + * More details: https://github.com/facebook/jest/pull/12789 */ ////////////////////////////////// @@ -16,10 +26,12 @@ const fabricEnvCAVersion = "1.4.9"; const ledgerUserName = "appUser"; const ledgerChannelName = "mychannel"; const ledgerContractName = "basic"; +const leaveLedgerRunning = false; // default: false +const useRunningLedger = false; // default: false // Log settings -const testLogLevel: LogLevelDesc = "info"; -const sutLogLevel: LogLevelDesc = "info"; +const testLogLevel: LogLevelDesc = "info"; // default: info +const sutLogLevel: LogLevelDesc = "info"; // default: info import { FabricTestLedgerV1, @@ -35,6 +47,8 @@ import { import { SocketIOApiClient } from "@hyperledger/cactus-api-client"; +import { signProposal } from "../../../main/typescript/connector/sign-utils"; + import { enrollAdmin, enrollUser, @@ -61,6 +75,7 @@ describe("Fabric-SocketIO connector tests", () => { let connectorCertValue: string; let connectorPrivKeyValue: string; let tmpWalletDir: string; + let connectorModule: typeof import("../../../main/typescript/index"); let connectorServer: HttpsServer; let apiClient: SocketIOApiClient; @@ -111,7 +126,7 @@ describe("Fabric-SocketIO connector tests", () => { orderer: { name: connectionProfile.orderers[ordererId].grpcOptions[ - "ssl-target-name-override" + "ssl-target-name-override" ], url: connectionProfile.orderers[ordererId].url, tlscaValue: connectionProfile.orderers[ordererId].tlsCACerts.pem, @@ -149,6 +164,30 @@ describe("Fabric-SocketIO connector tests", () => { log.info("Prune Docker..."); await pruneDockerAllIfGithubAction({ logLevel: testLogLevel }); + // Prepare local filesystem wallet path + if (leaveLedgerRunning || useRunningLedger) { + tmpWalletDir = path.join(os.tmpdir(), "fabric-test-wallet-common"); + log.warn("Using common wallet path when re-using the same ledger."); + try { + fs.mkdirSync(tmpWalletDir); + } catch (err) { + if (!err.message.includes("EEXIST")) { + log.error( + "Unexpected exception when creating common wallet dir:", + err, + ); + throw err; + } + } + } else { + log.info("Create temp dir for wallet - will be removed later..."); + tmpWalletDir = fs.mkdtempSync( + path.join(os.tmpdir(), "fabric-test-wallet"), + ); + } + log.info("Wallet path:", tmpWalletDir); + expect(tmpWalletDir).toBeTruthy(); + log.info("Start FabricTestLedgerV1..."); log.debug("Version:", fabricEnvVersion, "CA Version:", fabricEnvCAVersion); ledger = new FabricTestLedgerV1({ @@ -161,6 +200,7 @@ describe("Fabric-SocketIO connector tests", () => { ["FABRIC_VERSION", fabricEnvVersion], ["CA_VERSION", fabricEnvCAVersion], ]), + useRunningLedger, }); log.debug("Fabric image:", ledger.getContainerImageName()); await ledger.start(); @@ -173,10 +213,7 @@ describe("Fabric-SocketIO connector tests", () => { // Get admin credentials const [adminName, adminSecret] = ledger.adminCredentials; - // Setup wallet - log.info("Create temp dir for wallet - will be removed later..."); - tmpWalletDir = fs.mkdtempSync(path.join(os.tmpdir(), "fabric-test-wallet")); - expect(tmpWalletDir).toBeTruthy(); + // Enroll admin and user await enrollAdmin(connectionProfile, tmpWalletDir, adminName, adminSecret); await enrollUser( connectionProfile, @@ -205,7 +242,7 @@ describe("Fabric-SocketIO connector tests", () => { ); // Load connector module - const connectorModule = await import("../../../main/typescript/index"); + connectorModule = await import("../../../main/typescript/index"); // Run the connector connectorServer = await connectorModule.startFabricSocketIOConnector(); @@ -240,7 +277,7 @@ describe("Fabric-SocketIO connector tests", () => { afterAll(async () => { log.info("FINISHING THE TESTS"); - if (ledger) { + if (ledger && !leaveLedgerRunning && !useRunningLedger) { log.info("Stop the fabric ledger..."); await ledger.stop(); await ledger.destroy(); @@ -258,7 +295,7 @@ describe("Fabric-SocketIO connector tests", () => { ); } - if (tmpWalletDir) { + if (tmpWalletDir && !leaveLedgerRunning && !useRunningLedger) { log.info("Remove tmp wallet dir", tmpWalletDir); fs.rmSync(tmpWalletDir, { recursive: true }); } @@ -370,10 +407,112 @@ describe("Fabric-SocketIO connector tests", () => { return { signedCommitProposal, commitReq }; } + /** + * Calls connector function `generateUnsignedProposal`, assert correct response, and returns unsigned proposal. + * @param txProposal - Transaction data we want to send (CC function name, arguments, chancode ID, channel ID) + * @returns Unsigned proposal that can be signed locally and feed into `sendSignedProposalV2` + */ + async function getUnsignedProposal(txProposal: unknown, certPem: string) { + const contract = { channelName: ledgerChannelName }; + const method = { type: "function", command: "generateUnsignedProposal" }; + const argsParam = { + args: { + transactionProposalReq: txProposal, + certPem, + }, + }; + + log.info("Sending generateUnsignedProposal"); + + const response = await apiClient.sendSyncRequest( + contract, + method, + argsParam, + ); + expect(response).toBeTruthy(); + expect(response.status).toBe(200); + expect(response.data).toBeTruthy(); + expect(response.data.proposal).toBeTruthy(); + expect(response.data.proposalBuffer).toBeTruthy(); + expect(response.data.proposalBuffer.type).toEqual("Buffer"); + expect(response.data.proposalBuffer.data).toBeTruthy(); + expect(response.data.txId).toBeTruthy(); + + const proposalBuffer = Buffer.from(response.data.proposalBuffer); + expect(proposalBuffer).toBeTruthy(); + + log.info("Received correct response from generateUnsignedProposal"); + + return { + proposal: response.data.proposal, + proposalBuffer, + txId: response.data.txId, + }; + } + + /** + * Calls connector function `generateUnsignedTransaction`, assert correct response, and returns unsigned transaction (commit) proposal. + * @param txProposal - Transaction data we want to send (CC function name, arguments, chancode ID, channel ID) + * @param proposalResponses - Proposal resonses of endorsment step from `sendSignedProposalV2` call. + * @returns Unsigned commit proposal that can be signed locally and feed into `sendSignedTransactionV2` + */ + async function getUnsignedCommitProposal( + txProposal: unknown, + proposalResponses: unknown, + ) { + const contract = { channelName: ledgerChannelName }; + const method = { type: "function", command: "generateUnsignedTransaction" }; + const argsParam = { + args: { + proposal: txProposal, + proposalResponses: proposalResponses, + }, + }; + + log.info("Sending generateUnsignedTransaction"); + + const response = await apiClient.sendSyncRequest( + contract, + method, + argsParam, + ); + + expect(response).toBeTruthy(); + expect(response.status).toBe(200); + expect(response.data).toBeTruthy(); + expect(response.data.txProposalBuffer).toBeTruthy(); + expect(response.data.txProposalBuffer.type).toEqual("Buffer"); + expect(response.data.txProposalBuffer.data).toBeTruthy(); + + const commitProposalBuffer = Buffer.from(response.data.txProposalBuffer); + expect(commitProposalBuffer).toBeTruthy(); + + log.info("Received correct response from generateUnsignedTransaction"); + + return commitProposalBuffer; + } + ////////////////////////////////// // Tests ////////////////////////////////// + /** + * Test signProposal helper function from fabric-socketio connector module. + */ + test("Signing proposal creates a signature", async () => { + const [, privateKeyPem] = await getUserCryptoFromWallet( + ledgerUserName, + tmpWalletDir, + ); + + const proposal = Buffer.from("test test test test"); + const signedProposal = signProposal(proposal, privateKeyPem); + + expect(signedProposal).toBeTruthy(); + expect(signedProposal.proposal_bytes).toBeTruthy(); + expect(signedProposal.signature).toBeTruthy(); + }); + /** * Read all assets, get single asset, comapre that they return the same asset value without any errors. */ @@ -428,6 +567,105 @@ describe("Fabric-SocketIO connector tests", () => { expect(responseEncoded.data.data).toBeTruthy(); }); + /** + * Test entire process of sending transaction to the ledger without sharing the key with the connector. + * All proposals are signed on BLP (in this case, jest test) side. + * This test prepares proposal, signs it, sends for endorsment, prepares and sign commit proposal, sends commit transaction. + */ + test("Sending transaction signed on BLP side (without sharing the private key) works", async () => { + const [certPem, privateKeyPem] = await getUserCryptoFromWallet( + ledgerUserName, + tmpWalletDir, + ); + + // Prepare raw transaction proposal + const allAssets = await getAllAssets(); + const assetId = allAssets[1].ID; + const newOwnerName = "UnsignedSendTestXXX"; + const txProposal = { + fcn: "TransferAsset", + args: [assetId, newOwnerName], + chaincodeId: ledgerContractName, + channelId: ledgerChannelName, + }; + log.debug("Raw transaction proposal:", txProposal); + + // Get unsigned proposal + const { proposal, proposalBuffer } = await getUnsignedProposal( + txProposal, + certPem, + ); + + // Prepare signed proposal + const signedProposal = signProposal(proposalBuffer, privateKeyPem); + + // Call sendSignedProposalV2 + const contractSignedProposal = { channelName: ledgerChannelName }; + const methodSignedProposal = { + type: "function", + command: "sendSignedProposalV2", + }; + const argsSignedProposal = { + args: { + signedProposal, + }, + }; + + log.info("Sending sendSignedProposalV2"); + const responseSignedProposal = await apiClient.sendSyncRequest( + contractSignedProposal, + methodSignedProposal, + argsSignedProposal, + ); + + expect(responseSignedProposal).toBeTruthy(); + expect(responseSignedProposal.status).toBe(200); + expect(responseSignedProposal.data).toBeTruthy(); + expect(responseSignedProposal.data.endorsmentStatus).toBeTrue(); + expect(responseSignedProposal.data.proposalResponses).toBeTruthy(); + log.info("Received correct response from sendSignedProposalV2"); + const proposalResponses = responseSignedProposal.data.proposalResponses; + + // Get unsigned commit (transaction) proposal + const commitProposalBuffer = await getUnsignedCommitProposal( + proposal, + proposalResponses, + ); + + // Prepare signed commit proposal + const signedCommitProposal = signProposal( + commitProposalBuffer, + privateKeyPem, + ); + + // Call sendSignedTransactionV2 + const contractSignedTransaction = { channelName: ledgerChannelName }; + const methodSignedTransaction = { + type: "function", + command: "sendSignedTransactionV2", + }; + const argsSignedTransaction = { + args: { + signedCommitProposal: signedCommitProposal, + proposal: proposal, + proposalResponses: proposalResponses, + }, + }; + + log.info("Sending sendSignedTransactionV2"); + const responseSignedTransaction = await apiClient.sendSyncRequest( + contractSignedTransaction, + methodSignedTransaction, + argsSignedTransaction, + ); + + expect(responseSignedTransaction).toBeTruthy(); + expect(responseSignedTransaction.status).toBe(200); + expect(responseSignedTransaction.data).toBeTruthy(); + expect(responseSignedTransaction.data.status).toEqual("SUCCESS"); + log.info("Received correct response from sendSignedTransactionV2"); + }); + /** * Send transaction proposal to be signed with keys attached to the request (managed by BLP), * and then send signed transaction to the ledger. diff --git a/packages/cactus-plugin-ledger-connector-fabric-socketio/tsconfig.json b/packages/cactus-plugin-ledger-connector-fabric-socketio/tsconfig.json index 20649e4134..cc620be6a3 100644 --- a/packages/cactus-plugin-ledger-connector-fabric-socketio/tsconfig.json +++ b/packages/cactus-plugin-ledger-connector-fabric-socketio/tsconfig.json @@ -10,7 +10,8 @@ "./src/main/typescript/common/core/*.ts", "./src/main/typescript/common/core/bin/*.ts", "./src/main/typescript/common/core/config/*.ts", - "./src/main/typescript/connector/*.ts" + "./src/main/typescript/connector/*.ts", + "./src/main/typescript/*.ts" ], "references": [ { diff --git a/packages/cactus-test-tooling/src/main/typescript/fabric/fabric-test-ledger-v1.ts b/packages/cactus-test-tooling/src/main/typescript/fabric/fabric-test-ledger-v1.ts index e10ca92fe4..d5972967a9 100644 --- a/packages/cactus-test-tooling/src/main/typescript/fabric/fabric-test-ledger-v1.ts +++ b/packages/cactus-test-tooling/src/main/typescript/fabric/fabric-test-ledger-v1.ts @@ -56,6 +56,8 @@ export interface IFabricTestLedgerV1ConstructorOptions { stateDatabase?: STATE_DATABASE; orgList?: string[]; extraOrgs?: organizationDefinitionFabricV2[]; + // For test development, attach to ledger that is already running, don't spin up new one + useRunningLedger?: boolean; } export enum STATE_DATABASE { @@ -115,6 +117,7 @@ export class FabricTestLedgerV1 implements ITestLedger { private container: Container | undefined; private containerId: string | undefined; + private readonly useRunningLedger: boolean; public get className(): string { return FabricTestLedgerV1.CLASS_NAME; @@ -144,6 +147,10 @@ export class FabricTestLedgerV1 implements ITestLedger { `This version of Fabric ${this.getFabricVersion()} is unsupported`, ); + this.useRunningLedger = Bools.isBooleanStrict(options.useRunningLedger) + ? (options.useRunningLedger as boolean) + : false; + this.testLedgerId = `cactusf2aio.${this.imageVersion}.${Date.now()}`; this.validateConstructorOptions(); @@ -1215,6 +1222,25 @@ export class FabricTestLedgerV1 implements ITestLedger { public async start(ops?: LedgerStartOptions): Promise { const containerNameAndTag = this.getContainerImageName(); + + if (this.useRunningLedger) { + this.log.info( + "Search for already running Fabric Test Ledger because 'useRunningLedger' flag is enabled.", + ); + this.log.info( + "Search criteria - image name: ", + containerNameAndTag, + ", state: running", + ); + const containerInfo = await Containers.getByPredicate( + (ci) => ci.Image === containerNameAndTag && ci.State === "running", + ); + const docker = new Docker(); + this.containerId = containerInfo.Id; + this.container = docker.getContainer(this.containerId); + return this.container; + } + const dockerEnvVars = envMapToDocker(this.envVars); if (this.container) { @@ -1377,11 +1403,28 @@ export class FabricTestLedgerV1 implements ITestLedger { } public stop(): Promise { - return Containers.stop(this.container as Container); + if (this.useRunningLedger) { + this.log.info("Ignore stop request because useRunningLedger is enabled."); + return Promise.resolve(); + } else if (this.container) { + return Containers.stop(this.container); + } else { + return Promise.reject( + new Error(`Container was never created, nothing to stop.`), + ); + } } public async destroy(): Promise { const fnTag = "FabricTestLedgerV1#destroy()"; + + if (this.useRunningLedger) { + this.log.info( + "Ignore destroy request because useRunningLedger is enabled.", + ); + return Promise.resolve(); + } + try { if (!this.container) { throw new Error(`${fnTag} Container not found, nothing to destroy.`); diff --git a/tools/docker/fabric-all-in-one/healthcheck.sh b/tools/docker/fabric-all-in-one/healthcheck.sh index 457ea4ed06..1cdfcaf206 100755 --- a/tools/docker/fabric-all-in-one/healthcheck.sh +++ b/tools/docker/fabric-all-in-one/healthcheck.sh @@ -18,6 +18,7 @@ function main() # Major version is 2 or newer (we'll deal with 3.x when it is released) cd /fabric-samples/test-network/ peer chaincode query -C mychannel -n basic -c '{"Args": [], "Function": "GetAllAssets"}' + peer chaincode query -C mychannel -n basic -c '{"Args": ["asset1"], "Function": "ReadAsset"}' else # Major version is 1.x or earlier (assumption is 1.4.x only) docker exec cli peer chaincode query --channelID mychannel --name fabcar --ctor '{"Args": [], "Function": "queryAllCars"}'