diff --git a/.github/workflows/build-docs.yml b/.github/workflows/build-docs.yml index 4db8aab5b..8997d08bf 100644 --- a/.github/workflows/build-docs.yml +++ b/.github/workflows/build-docs.yml @@ -40,6 +40,22 @@ jobs: name: java-doc path: java/target/reports/apidocs/ + web: + runs-on: ubuntu-22.04 + name: Web documentation + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: "lts/*" + - name: Generate documentation + run: make generate-docs-web + - name: Upload documentation + uses: actions/upload-artifact@v4 + with: + name: web-doc + path: web/apidocs/ + site: runs-on: ubuntu-24.04 name: Documentation site diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index f795d1e2b..75d9a6e0f 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -207,3 +207,20 @@ jobs: run: make pull-docker-images - name: Run scenario tests run: make scenario-test-java + + web_unit: + needs: verify-versions + runs-on: ubuntu-22.04 + name: Unit test Web + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + - name: Run unit tests + run: make unit-test-web + - name: Upload bundle + uses: actions/upload-artifact@v4 + with: + name: web-bundle + path: web/fabric-gateway-web.js diff --git a/Makefile b/Makefile index 4f6b6c3c1..5da4b8bee 100644 --- a/Makefile +++ b/Makefile @@ -9,6 +9,7 @@ base_dir := $(patsubst %/,%,$(dir $(realpath $(lastword $(MAKEFILE_LIST))))) go_dir := $(base_dir)/pkg node_dir := $(base_dir)/node java_dir := $(base_dir)/java +web_dir := $(base_dir)/web scenario_dir := $(base_dir)/scenario go_bin_dir := $(shell go env GOPATH)/bin @@ -48,6 +49,12 @@ build-java: cd '$(java_dir)' && \ mvn -DskipTests install +.PHONY: build-web +build-web: + cd "$(web_dir)" && \ + npm install && \ + npm run build + .PHONY: unit-test unit-test: generate lint unit-test-go unit-test-node unit-test-java @@ -71,6 +78,11 @@ unit-test-java: cd '$(java_dir)' && \ mvn test jacoco:report +.PHONY: unit-test-web +unit-test-web: build-web + cd "$(web_dir)" && \ + npm test + .PHONY: lint lint: staticcheck golangci-lint @@ -233,6 +245,12 @@ generate-docs-java: cd '$(java_dir)' && \ mvn javadoc:javadoc +.PHONY: generate-docs-web +generate-docs-web: + cd "$(web_dir)" && \ + npm install && \ + npm run generate-apidoc + .PHONY: test test: shellcheck unit-test scenario-test diff --git a/README.md b/README.md index a5aa3c527..7b18a47df 100644 --- a/README.md +++ b/README.md @@ -60,6 +60,7 @@ The following Makefile targets are available: - `make unit-test-go-pkcs11` - run unit tests for the Go client API, including HSM tests - `make unit-test-node` - run unit tests for the Node client API - `make unit-test-java` - run unit tests for the Java client API +- `make unit-test-web` - run unit tests for the Web client API - `make unit-test` - run unit tests for all client language implementations - `make scenario-test-go` - run the scenario (end to end integration) tests for Go client API, including HSM tests - `make scenario-test-go-no-hsm` - run the scenario (end to end integration) tests for Go client API, excluding HSM tests diff --git a/node/src/signingidentity.ts b/node/src/signingidentity.ts index a70f05aa7..bed110c0f 100644 --- a/node/src/signingidentity.ts +++ b/node/src/signingidentity.ts @@ -14,7 +14,7 @@ import { Signer } from './identity/signer'; export const undefinedSignerMessage = 'No signing implementation'; const undefinedSigner: Signer = () => { - throw new Error(undefinedSignerMessage); + return Promise.reject(new Error(undefinedSignerMessage)); }; type SigningIdentityOptions = Pick; @@ -55,7 +55,7 @@ export class SigningIdentity { return this.#hash(message); } - async sign(digest: Uint8Array): Promise { + sign(digest: Uint8Array): Promise { return this.#sign(digest); } } diff --git a/scenario/fixtures/rest/.gitignore b/scenario/fixtures/rest/.gitignore new file mode 100644 index 000000000..e848f859b --- /dev/null +++ b/scenario/fixtures/rest/.gitignore @@ -0,0 +1,4 @@ +node_modules/ +dist/ +coverage/ +package-lock.json diff --git a/scenario/fixtures/rest/package.json b/scenario/fixtures/rest/package.json new file mode 100644 index 000000000..a695ceafd --- /dev/null +++ b/scenario/fixtures/rest/package.json @@ -0,0 +1,33 @@ +{ + "name": "fabric-gateway-rest", + "version": "0.0.1", + "description": "REST server for fabric-gateway-web clients", + "main": "dist/index.js", + "engines": { + "node": ">=18.12.0" + }, + "scripts": { + "format": "prettier '**/*.{ts,js}' --check", + "format:fix": "prettier '**/*.{ts,js}' --write", + "lint": "eslint .", + "postinstall": "tsc", + "test": "echo \"Error: no test specified\" && exit 1" + }, + "license": "Apache-2.0", + "dependencies": { + "@grpc/grpc-js": "^1.10.4", + "@hyperledger/fabric-gateway": "file:../../../node/fabric-gateway-dev.tgz", + "express": "^4.19.2" + }, + "devDependencies": { + "@tsconfig/node18": "^18.2.2", + "@types/express": "^4.17.21", + "@types/node": "^18.19.22", + "@typescript-eslint/eslint-plugin": "~7.3.1", + "@typescript-eslint/parser": "~7.3.1", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "prettier": "^3.2.5", + "typescript": "~5.4.2" + } +} diff --git a/scenario/fixtures/rest/src/gateway.ts b/scenario/fixtures/rest/src/gateway.ts new file mode 100644 index 000000000..e1f8c6cad --- /dev/null +++ b/scenario/fixtures/rest/src/gateway.ts @@ -0,0 +1,37 @@ +import { Gateway } from '@hyperledger/fabric-gateway'; +import express, { Express } from 'express'; +import { Server } from './server'; + +const REST_PORT = 3000; + +export interface GatewayServerOptions { + port: number; + gateway: Gateway; +} + +export class GatewayServer { + #gateway: Gateway; + #server: Server; + + constructor(options: GatewayServerOptions) { + this.#gateway = options.gateway; + this.#server = new Server({ + port: options.port, + handlers: [this.#evaluate], + }); + } + + start(): Promise { + return this.#server.start(); + } + + stop(): Promise { + return this.#server.stop(); + } + + #evaluate(app: Express): void { + app.post('/evaluate', express.json(), (request, response) => { + request.body.proposal; + }); + } +} diff --git a/scenario/fixtures/rest/src/index.ts b/scenario/fixtures/rest/src/index.ts new file mode 100644 index 000000000..e69de29bb diff --git a/scenario/fixtures/rest/src/server.ts b/scenario/fixtures/rest/src/server.ts new file mode 100644 index 000000000..e0317f3ba --- /dev/null +++ b/scenario/fixtures/rest/src/server.ts @@ -0,0 +1,28 @@ +import express, { Express } from 'express'; +import * as http from 'node:http'; + +export interface ServerOptions { + port: number; + handlers: ((app: Express) => void)[]; +} + +export class Server { + readonly #app = express(); + readonly #port: number; + #server?: http.Server; + + constructor(options: ServerOptions) { + this.#port = options.port; + options.handlers.forEach((handler) => handler(this.#app)); + } + + start(): Promise { + return new Promise((resolve) => { + this.#server = this.#app.listen(this.#port, resolve); + }); + } + + stop(): Promise { + return new Promise((resolve, reject) => this.#server?.close((err) => (err ? resolve() : reject(err)))); + } +} diff --git a/scenario/fixtures/rest/tsconfig.json b/scenario/fixtures/rest/tsconfig.json new file mode 100644 index 000000000..4abed6928 --- /dev/null +++ b/scenario/fixtures/rest/tsconfig.json @@ -0,0 +1,15 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "@tsconfig/node18/tsconfig.json", + "compilerOptions": { + "declaration": false, + "sourceMap": true, + "outDir": "dist", + "rootDir": "src", + "strict": true, + "noUnusedLocals": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true + }, + "include": ["src/"] +} diff --git a/scenario/node/package-lock.json b/scenario/node/package-lock.json index 7c1b106de..1832e7209 100644 --- a/scenario/node/package-lock.json +++ b/scenario/node/package-lock.json @@ -554,7 +554,8 @@ "node_modules/@hyperledger/fabric-gateway": { "version": "1.7.1", "resolved": "file:../../node/fabric-gateway-dev.tgz", - "integrity": "sha512-OMDUxHayDiZ/CS5+hWwvNCIPfdbimAWZG1L05W3RrcKr5IjHrxhoL8WCnnLGTO7909KQA0QV9y7pTD/wZcy7bg==", + "integrity": "sha512-/HlN/qTtyaDQaDiH0pg4iwurgbcf3YstAl/wxmNCTZw7jlj6D5zTIY9R5r6HPnJdaoKxorDFxUdIPXjI6qJR4Q==", + "license": "Apache-2.0", "dependencies": { "@grpc/grpc-js": "^1.11.0", "@hyperledger/fabric-protos": "^0.3.0", diff --git a/web/.eslintrc.base.js b/web/.eslintrc.base.js new file mode 100644 index 000000000..78577f20c --- /dev/null +++ b/web/.eslintrc.base.js @@ -0,0 +1,26 @@ +module.exports = { + env: { + node: true, + es2023: true, + }, + parser: '@typescript-eslint/parser', + parserOptions: { + sourceType: 'module', + ecmaFeatures: { + impliedStrict: true, + }, + project: './tsconfig.json', + tsconfigRootDir: process.env.TSCONFIG_ROOT_DIR || __dirname, + }, + plugins: ['@typescript-eslint'], + extends: ['eslint:recommended', 'plugin:@typescript-eslint/strict-type-checked', 'prettier'], + rules: { + complexity: ['error', 10], + '@typescript-eslint/explicit-function-return-type': [ + 'error', + { + allowExpressions: true, + }, + ], + }, +}; diff --git a/web/.eslintrc.js b/web/.eslintrc.js new file mode 100644 index 000000000..51b26bdcd --- /dev/null +++ b/web/.eslintrc.js @@ -0,0 +1,12 @@ +module.exports = { + root: true, + env: { + jest: true, + }, + ignorePatterns: ['*/**', '*.js', '*.ts', '!src/**/*.ts'], + plugins: ['jest', 'eslint-plugin-tsdoc'], + extends: ['.eslintrc.base', 'plugin:jest/recommended'], + rules: { + 'tsdoc/syntax': ['error'], + }, +}; diff --git a/web/.gitignore b/web/.gitignore new file mode 100644 index 000000000..ac76844ff --- /dev/null +++ b/web/.gitignore @@ -0,0 +1,10 @@ +node_modules/ +dist/ +coverage/ +*.tgz +src/protos/ +apidocs/ +package-lock.json +sbom.json +fabric-protos-*/ +fabric-gateway-web.js diff --git a/web/.npmignore b/web/.npmignore new file mode 100644 index 000000000..2ae02177b --- /dev/null +++ b/web/.npmignore @@ -0,0 +1,4 @@ +# Exclude everything except specific inclusions below +**/* + +!dist/**/* diff --git a/web/.npmrc b/web/.npmrc new file mode 100644 index 000000000..b6f27f135 --- /dev/null +++ b/web/.npmrc @@ -0,0 +1 @@ +engine-strict=true diff --git a/web/README.md b/web/README.md new file mode 100644 index 000000000..b4415d87e --- /dev/null +++ b/web/README.md @@ -0,0 +1,3 @@ +# Hyperledger Fabric Gateway Client API for Web + +The Fabric Gateway client API for Web bundle helps browser applications to interact with a Hyperledger Fabric blockchain network using an intermediary service. It implements a subset of the Fabric programming model, providing a simple API to generate signed transaction proposals with minimal code. diff --git a/web/jest.config.js b/web/jest.config.js new file mode 100644 index 000000000..e0afc89cd --- /dev/null +++ b/web/jest.config.js @@ -0,0 +1,11 @@ +module.exports = { + roots: ['/src'], + preset: 'ts-jest', + testEnvironment: 'node', + collectCoverage: true, + collectCoverageFrom: ['**/*.[jt]s?(x)', '!**/*.d.ts'], + coverageProvider: 'v8', + testMatch: ['**/?(*.)+(spec|test).[jt]s?(x)'], + verbose: true, + workerThreads: true, +}; diff --git a/web/package.json b/web/package.json new file mode 100644 index 000000000..333e97f72 --- /dev/null +++ b/web/package.json @@ -0,0 +1,57 @@ +{ + "name": "@hyperledger/fabric-gateway-web", + "version": "1.5.0", + "description": "Hyperledger Fabric Gateway client API for Web browsers", + "main": "dist/index.js", + "types": "dist/index.d.ts", + "engines": { + "node": ">=18.12.0" + }, + "repository": { + "type": "git", + "url": "https://github.com/hyperledger/fabric-gateway" + }, + "bugs": "https://github.com/hyperledger/fabric-gateway/issues", + "homepage": "https://hyperledger.github.io/fabric-gateway/", + "author": { + "name": "hyperledger/fabric", + "email": "fabric@lists.hyperledger.org", + "url": "https://www.hyperledger.org/use/fabric" + }, + "scripts": { + "build": "npm-run-all clean compile copy-non-ts-source bundle", + "bundle": "esbuild dist/index.js --bundle --minify --outfile=fabric-gateway-web.js --analyze", + "clean": "rm -rf apidocs dist", + "compile": "tsc --project tsconfig.build.json", + "copy-non-ts-source": "rsync -rv --prune-empty-dirs --include='*.d.ts' --exclude='*.ts' src/ dist", + "format": "prettier '**/*.{ts,js}' --check", + "format:fix": "prettier '**/*.{ts,js}' --write", + "generate-apidoc": "typedoc", + "lint": "eslint .", + "sbom": "cyclonedx-npm --omit dev --output-format JSON --output-file sbom.json", + "test": "npm-run-all lint format unit-test", + "unit-test": "NODE_OPTIONS='--experimental-global-webcrypto' jest" + }, + "license": "Apache-2.0", + "dependencies": { + "@hyperledger/fabric-protos": "0.3.3", + "google-protobuf": "^3.21.0" + }, + "devDependencies": { + "@types/google-protobuf": "^3.15.12", + "@types/jest": "^29.5.12", + "@typescript-eslint/eslint-plugin": "~7.5.0", + "@typescript-eslint/parser": "~7.5.0", + "esbuild": "^0.20.2", + "eslint": "^8.57.0", + "eslint-config-prettier": "^9.1.0", + "eslint-plugin-jest": "^27.9.0", + "eslint-plugin-tsdoc": "^0.2.17", + "jest": "^29.7.0", + "npm-run-all": "^4.1.5", + "prettier": "^3.2.5", + "ts-jest": "^29.1.2", + "typedoc": "^0.25.11", + "typescript": "~5.4.3" + } +} diff --git a/web/src/README.md b/web/src/README.md new file mode 100644 index 000000000..c97a04044 --- /dev/null +++ b/web/src/README.md @@ -0,0 +1,81 @@ +# Overview + +The _fabric-gateway-web_ bundle enables Web developers to create signed transaction artifacts in in browser applications. These artifacts can then be serialized and sent to an intermediary service to interact with Fabric on behalf of the browser client, using the [Fabric Gateway client API](https://hyperledger.github.io/fabric-gateway/). + +## Getting started + +A session for a given client identity is created by calling `connect()` with a client identity, and client signing implementation. The returned `Gateway` enables interaction with any of the blockchain `Networks` (channels) accessible through the Fabric Gateway. This in turn provides access to Smart `Contracts` within chaincode deployed to that blockchain network, and to which transactions can be submitted or queries can be evaluated. + +To **evaluate** a smart contract transaction function, querying ledger state: + +1. The client: + 1. Creates a signed transaction `Proposal`. + 1. Serializes the `Proposal` and sends it to the intermediary service. +1. The intermediary service: + 1. Deserializes the data into a Proposal object. + 1. On behalf of the client, _evaluates_ the transaction proposal. + 1. Returns the response to the client. + +To **submit** a transaction, updating ledger state: + +1. The client: + 1. Creates a signed transaction `Proposal`. + 1. Serializes the `Proposal` and sends it to the intermediary service. +1. The intermediary service: + 1. Deserializes the data into a Proposal object. + 1. On behalf of the client, _endorses_ the transaction proposal. + 1. Serializes the resulting Transaction object and returns it to the client. +1. The client: + 1. Deserializes the data to create a signed `Transaction`. + 1. Serializes the signed `Transaction` and sends it to the intermediary service. +1. The intermediary service: + 1. Deserializes the data into a signed Transaction object. + 1. On behalf of the client, _submits_ the transaction. + 1. Waits for the transaction commit status. + 1. Returns the result to the client. + +## Example + +A Gateway connection is created using a client identity and a signing implementation: + +```JavaScript +const identity = { + mspId: 'myorg', + credentials, +}; + +const signer = async (message) => { + const signature = await globalThis.crypto.subtle.sign( + { name: 'ECDSA', hash: 'SHA-256' }, + privateKey, + message, + ); + return new Uint8Array(signature); +}; + +const gateway = connect({ identity, signer }); +``` + +The following example shows how to create a signed transaction proposal. The serialized proposal can be sent to an intermediary service, which can then use it to interact with Fabric on the Web client's behalf. + +```JavaScript +const network = gateway.getNetwork('channelName'); +const contract = network.getContract('chaincodeName'); + +const proposal = await contract.newProposal('transactionName', { + arguments: ['one', 'two'], +}); +const proposalBytes = proposal.getBytes(); +``` + +The following example shows how to create a signed transaction from an endorsed transaction received from an intermediary service following proposal endorsement. The transaction results can be inspected, and the serialized transaction can be sent to an intermediary service, which can then submit it to Fabric to update the ledger. + +```JavaScript +const transaction = await gateway.newTransaction(endorsedTransactionBytes); +if (transaction.getTransactionId() !== proposal.getTransactionId()) { + // Not the expected response so might be from a malicious actor. +} + +const result = transaction.getResult(); +const transactionBytes = transaction.getBytes(); +``` diff --git a/web/src/contract.ts b/web/src/contract.ts new file mode 100644 index 000000000..ab0eb9343 --- /dev/null +++ b/web/src/contract.ts @@ -0,0 +1,89 @@ +/* + * Copyright 2020 IBM All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Proposal } from './proposal'; +import { ProposalBuilder, ProposalOptions } from './proposalbuilder'; +import { SigningIdentity } from './signingidentity'; + +/** + * Represents a smart contract, and allows applications to create transaction proposals using {@link newProposal}. The + * proposal can be serialized and sent to a remote server that will interact with the Fabric network on behalf of the + * client application. + * + * @example + * ```typescript + * const proposal = await contract.newProposal('transactionName', { + * arguments: ['one', 'two'], + * // Specify additional proposal options here + * }); + * const serializedProposal = proposal.getBytes(); + * ``` + */ +export interface Contract { + /** + * Get the name of the chaincode that contains this smart contract. + * @returns The chaincode name. + */ + getChaincodeName(): string; + + /** + * Get the name of the smart contract within the chaincode. + * @returns The contract name, or `undefined` for the default smart contract. + */ + getContractName(): string | undefined; + + /** + * Create a transaction proposal that can be evaluated or endorsed. Supports off-line signing flow. + * @param transactionName - Name of the transaction to invoke. + * @param options - Transaction invocation options. + */ + newProposal(transactionName: string, options?: ProposalOptions): Promise; +} + +export interface ContractOptions { + signingIdentity: SigningIdentity; + channelName: string; + chaincodeName: string; + contractName?: string; +} + +export class ContractImpl implements Contract { + readonly #signingIdentity: SigningIdentity; + readonly #channelName: string; + readonly #chaincodeName: string; + readonly #contractName?: string; + + constructor(options: Readonly) { + this.#signingIdentity = options.signingIdentity; + this.#channelName = options.channelName; + this.#chaincodeName = options.chaincodeName; + this.#contractName = options.contractName; + } + + getChaincodeName(): string { + return this.#chaincodeName; + } + + getContractName(): string | undefined { + return this.#contractName; + } + + async newProposal(transactionName: string, options: Readonly = {}): Promise { + const builder = await ProposalBuilder.newInstance( + Object.assign({}, options, { + signingIdentity: this.#signingIdentity, + channelName: this.#channelName, + chaincodeName: this.#chaincodeName, + transactionName: this.#getQualifiedTransactionName(transactionName), + }), + ); + return builder.build(); + } + + #getQualifiedTransactionName(transactionName: string): string { + return this.#contractName ? `${this.#contractName}:${transactionName}` : transactionName; + } +} diff --git a/web/src/crypto.ts b/web/src/crypto.ts new file mode 100644 index 000000000..4e332c48e --- /dev/null +++ b/web/src/crypto.ts @@ -0,0 +1,38 @@ +/* + * Copyright IBM Corp. All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +declare const globalThis: + | { + crypto?: Partial; + } + | undefined; + +const crypto: Partial = globalThis?.crypto ?? {}; + +const getRandomValues: (array: T) => T = ( + crypto.getRandomValues ?? error('No globalThis.crypto.getRandomValues defined') +).bind(crypto); + +export function randomBytes(size: number): Uint8Array { + return getRandomValues(new Uint8Array(size)); +} + +const subtle: Partial = crypto.subtle ?? {}; + +const digest: (algorithm: AlgorithmIdentifier, data: BufferSource) => Promise = ( + subtle.digest ?? error('No globalThis.crypto.subtle.digest defined') +).bind(subtle); + +export async function sha256(message: Uint8Array): Promise { + const result = await digest('SHA-256', message); + return new Uint8Array(result); +} + +function error(message: string): () => never { + return () => { + throw new Error(message); + }; +} diff --git a/web/src/gateway.test.ts b/web/src/gateway.test.ts new file mode 100644 index 000000000..9fd210616 --- /dev/null +++ b/web/src/gateway.test.ts @@ -0,0 +1,57 @@ +/* + * Copyright 2020 IBM All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { connect, ConnectOptions, Identity } from '.'; + +const utf8Encoder = new TextEncoder(); + +describe('Gateway', () => { + let identity: Identity; + let connectOptions: ConnectOptions; + + beforeEach(() => { + identity = { + mspId: 'MSP_ID', + credentials: utf8Encoder.encode('CERTIFICATE'), + }; + connectOptions = { + identity, + signer: (message) => Promise.resolve(message), + }; + }); + + describe('connect', () => { + it('throws if no identity supplied', () => { + const options = Object.assign(connectOptions, { identity: undefined }); + expect(() => connect(options)).toThrow(); + }); + it('throws if no signer supplied', () => { + const options = Object.assign(connectOptions, { signer: undefined }); + expect(() => connect(options)).toThrow(); + }); + }); + + describe('getNetwork', () => { + it('returns correctly named network', () => { + const gateway = connect(connectOptions); + + const network = gateway.getNetwork('CHANNEL_NAME'); + + expect(network.getName()).toBe('CHANNEL_NAME'); + }); + }); + + describe('getIdentity', () => { + it('returns supplied identity', () => { + const gateway = connect(connectOptions); + + const result = gateway.getIdentity(); + + expect(result.mspId).toEqual(identity.mspId); + expect(new Uint8Array(result.credentials)).toEqual(new Uint8Array(identity.credentials)); + }); + }); +}); diff --git a/web/src/gateway.ts b/web/src/gateway.ts new file mode 100644 index 000000000..1e215ecf7 --- /dev/null +++ b/web/src/gateway.ts @@ -0,0 +1,144 @@ +/* + * Copyright 2020 IBM All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PreparedTransaction } from '@hyperledger/fabric-protos/lib/gateway/gateway_pb'; +import { Identity } from './identity'; +import { Network, NetworkImpl } from './network'; +import { Signer } from './signer'; +import { SigningIdentity } from './signingidentity'; +import { Transaction, TransactionImpl } from './transaction'; + +/** + * Options used when connecting to a Fabric Gateway. + * @example + * ```typescript + * const options: ConnectOptions { + * identity: { + * mspId: 'myorg', + * credentials, + * }, + * signer: async (message) => { + * const signature = await globalThis.crypto.subtle.sign( + * { name: 'ECDSA', hash: 'SHA-256' }, + * privateKey, + * message, + * ); + * return new Uint8Array(signature); + * }, + * }; + * ``` + */ +export interface ConnectOptions { + /** + * Client identity used by the gateway. + */ + identity: Identity; + + /** + * Signing implementation used to sign messages sent by the gateway. + */ + signer: Signer; +} + +/** + * Connect to a Fabric Gateway using a client identity and signing implementation. + * @param options - Connection options. + * @returns A connected gateway. + */ +export function connect(options: Readonly): Gateway { + assertDefined(options.identity, 'No identity supplied'); + assertDefined(options.signer, 'No signer supplied'); + + const signingIdentity = new SigningIdentity(options); + + return new GatewayImpl({ + signingIdentity, + }); +} + +/** + * Gateway represents the connection of a specific client identity to a Fabric Gateway. A Gateway is obtained using the + * {@link connect} function. + */ +export interface Gateway { + /** + * Get the identity used by this gateway. + */ + getIdentity(): Identity; + + /** + * Get a network representing the named Fabric channel. + * @param channelName - Fabric channel name. + */ + getNetwork(channelName: string): Network; + + /** + * Recreate a transaction from serialized data. + * @param bytes - Serialized proposal. + * @returns A transaction. + * @throws Error if the transaction creator does not match the client identity. + */ + newTransaction(bytes: Uint8Array): Promise; +} + +interface GatewayOptions { + signingIdentity: SigningIdentity; +} + +class GatewayImpl implements Gateway { + readonly #signingIdentity: SigningIdentity; + + constructor(options: Readonly) { + this.#signingIdentity = options.signingIdentity; + } + + getIdentity(): Identity { + return this.#signingIdentity.getIdentity(); + } + + getNetwork(channelName: string): Network { + return new NetworkImpl({ + signingIdentity: this.#signingIdentity, + channelName, + }); + } + + async newTransaction(bytes: Uint8Array): Promise { + const preparedTransaction = PreparedTransaction.deserializeBinary(bytes); + + const result = await TransactionImpl.newInstance({ + signingIdentity: this.#signingIdentity, + preparedTransaction, + }); + + const identity = result.getIdentity(); + if (!equalIdentity(identity, this.getIdentity())) { + throw new Error('Transaction creator does not match client identity'); + } + + return result; + } +} + +export function assertDefined(value: T | null | undefined, message: string): T { + if (value == undefined) { + throw new Error(message); + } + + return value; +} + +function equalIdentity(a: Identity, b: Identity): boolean { + return a.mspId === b.mspId && equalBytes(a.credentials, b.credentials); +} + +function equalBytes(a: Uint8Array, b: Uint8Array): boolean { + if (a.length !== b.length) { + return false; + } + + return a.every((value, i) => value === b[i]); +} diff --git a/web/src/identity.d.ts b/web/src/identity.d.ts new file mode 100644 index 000000000..d2f11bb75 --- /dev/null +++ b/web/src/identity.d.ts @@ -0,0 +1,22 @@ +/* + * Copyright 2020 IBM All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * Represents a client identity used to interact with a Fabric network. The identity consists of an identifier for the + * organization to which the identity belongs, and implementation-specific credentials describing the identity. + */ +export interface Identity { + /** + * Member services provider to which this identity is associated. + */ + mspId: string; + + /** + * Implementation-specific credentials. For an identity described by a X.509 certificate, the credentials are the + * PEM-encoded certificate. + */ + credentials: Uint8Array; +} diff --git a/web/src/index.ts b/web/src/index.ts new file mode 100644 index 000000000..8f8604b5d --- /dev/null +++ b/web/src/index.ts @@ -0,0 +1,14 @@ +/* + * Copyright 2020 IBM All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +export { Contract } from './contract'; +export { ConnectOptions, Gateway, connect } from './gateway'; +export { Identity } from './identity'; +export { Network } from './network'; +export { Proposal } from './proposal'; +export { ProposalOptions } from './proposalbuilder'; +export { Signer } from './signer'; +export { Transaction } from './transaction'; diff --git a/web/src/network.test.ts b/web/src/network.test.ts new file mode 100644 index 000000000..f4b4916c4 --- /dev/null +++ b/web/src/network.test.ts @@ -0,0 +1,43 @@ +/* + * Copyright 2020 IBM All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { connect, ConnectOptions, Identity, Network } from '.'; + +const utf8Encoder = new TextEncoder(); + +describe('Network', () => { + let network: Network; + + beforeEach(() => { + const identity: Identity = { + mspId: 'MSP_ID', + credentials: utf8Encoder.encode('CERTIFICATE'), + }; + const options: ConnectOptions = { + identity, + signer: (message) => Promise.resolve(message), + }; + + const gateway = connect(options); + network = gateway.getNetwork('CHANNEL_NAME'); + }); + + describe('getContract', () => { + it('returns correctly named default contract', () => { + const contract = network.getContract('CHAINCODE_NAME'); + + expect(contract.getChaincodeName()).toBe('CHAINCODE_NAME'); + expect(contract.getContractName()).toBeUndefined(); + }); + + it('returns correctly named non-default contract', () => { + const contract = network.getContract('CHAINCODE_NAME', 'CONTRACT_NAME'); + + expect(contract.getChaincodeName()).toBe('CHAINCODE_NAME'); + expect(contract.getContractName()).toBe('CONTRACT_NAME'); + }); + }); +}); diff --git a/web/src/network.ts b/web/src/network.ts new file mode 100644 index 000000000..2c121ac72 --- /dev/null +++ b/web/src/network.ts @@ -0,0 +1,56 @@ +/* + * Copyright 2020 IBM All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { Contract, ContractImpl } from './contract'; +import { SigningIdentity } from './signingidentity'; + +/** + * Network represents a network of nodes that are members of a specific Fabric channel. The Network can be used to + * access deployed smart contracts. Network instances are obtained from a Gateway using the {@link Gateway.getNetwork} + * method. + */ +export interface Network { + /** + * Get the name of the Fabric channel this network represents. + */ + getName(): string; + + /** + * Get a smart contract within the named chaincode. If no contract name is supplied, this is the default smart + * contract for the named chaincode. + * @param chaincodeName - Chaincode name. + * @param contractName - Smart contract name. + */ + getContract(chaincodeName: string, contractName?: string): Contract; +} + +export interface NetworkOptions { + signingIdentity: SigningIdentity; + channelName: string; +} + +export class NetworkImpl implements Network { + readonly #signingIdentity: SigningIdentity; + readonly #channelName: string; + + constructor(options: Readonly) { + this.#signingIdentity = options.signingIdentity; + this.#channelName = options.channelName; + } + + getName(): string { + return this.#channelName; + } + + getContract(chaincodeName: string, contractName?: string): Contract { + return new ContractImpl({ + signingIdentity: this.#signingIdentity, + channelName: this.#channelName, + chaincodeName: chaincodeName, + contractName, + }); + } +} diff --git a/web/src/proposal.test.ts b/web/src/proposal.test.ts new file mode 100644 index 000000000..75d0e796a --- /dev/null +++ b/web/src/proposal.test.ts @@ -0,0 +1,213 @@ +/* + * Copyright 2020 IBM All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { common, gateway as gatewayproto, msp, peer } from '@hyperledger/fabric-protos'; +import { Contract, Gateway, Identity, Network, Proposal, connect } from '.'; +import { assertDefined } from './gateway'; + +const utf8Encoder = new TextEncoder(); +const utf8Decoder = new TextDecoder(); + +function decodeProposedTransaction(proposal: Proposal): gatewayproto.ProposedTransaction { + return gatewayproto.ProposedTransaction.deserializeBinary(proposal.getBytes()); +} + +function assertDecodeProposal(proposal: Proposal): peer.Proposal { + const proposedTransaction = decodeProposedTransaction(proposal); + let proposalBytes = proposedTransaction.getProposal()?.getProposalBytes_asU8(); + proposalBytes = assertDefined(proposalBytes, 'proposalBytes is undefined'); + return peer.Proposal.deserializeBinary(proposalBytes); +} + +function assertDecodeChaincodeSpec(proposal: peer.Proposal): peer.ChaincodeSpec { + const payload = peer.ChaincodeProposalPayload.deserializeBinary(proposal.getPayload_asU8()); + const invocationSpec = peer.ChaincodeInvocationSpec.deserializeBinary(payload.getInput_asU8()); + const chaincodeSpec = invocationSpec.getChaincodeSpec(); + return assertDefined(chaincodeSpec, 'chaincodeSpec is undefined'); +} + +function assertDecodeArgsAsStrings(proposal: peer.Proposal): string[] { + const chaincodeSpec = assertDecodeChaincodeSpec(proposal); + const input = assertDefined(chaincodeSpec.getInput(), 'input is undefined'); + const args = input.getArgsList_asU8(); + return args.map((arg) => utf8Decoder.decode(arg)); +} + +function assertDecodeHeader(proposal: peer.Proposal): common.Header { + return common.Header.deserializeBinary(proposal.getHeader_asU8()); +} + +function assertDecodeSignatureHeader(proposal: peer.Proposal): common.SignatureHeader { + const header = assertDecodeHeader(proposal); + return common.SignatureHeader.deserializeBinary(header.getSignatureHeader_asU8()); +} + +function assertDecodeChannelHeader(proposal: peer.Proposal): common.ChannelHeader { + const header = assertDecodeHeader(proposal); + return common.ChannelHeader.deserializeBinary(header.getChannelHeader_asU8()); +} + +describe('Proposal', () => { + let identity: Identity; + let signer: jest.Mock, Uint8Array[]>; + let gateway: Gateway; + let network: Network; + let contract: Contract; + + beforeEach(() => { + identity = { + mspId: 'MSP_ID', + credentials: utf8Encoder.encode('CERTIFICATE'), + }; + signer = jest.fn(undefined); + signer.mockResolvedValue(utf8Encoder.encode('SIGNATURE')); + + gateway = connect({ + identity, + signer, + }); + network = gateway.getNetwork('CHANNEL_NAME'); + contract = network.getContract('CHAINCODE_NAME'); + }); + + it('includes channel name', async () => { + const result = await contract.newProposal('TRANSACTION_NAME'); + + const proposal = assertDecodeProposal(result); + const channelHeader = assertDecodeChannelHeader(proposal); + expect(channelHeader.getChannelId()).toBe(network.getName()); + }); + + it('includes chaincode name', async () => { + const result = await contract.newProposal('TRANSACTION_NAME'); + + const proposal = assertDecodeProposal(result); + const chaincodeSpec = assertDecodeChaincodeSpec(proposal); + expect(chaincodeSpec.getChaincodeId()).toBeDefined(); + expect(chaincodeSpec.getChaincodeId()?.getName()).toBe(contract.getChaincodeName()); + }); + + it('includes transaction name for default smart contract', async () => { + const result = await contract.newProposal('MY_TRANSACTION'); + + const proposal = assertDecodeProposal(result); + const argStrings = assertDecodeArgsAsStrings(proposal); + expect(argStrings[0]).toBe('MY_TRANSACTION'); + }); + + it('includes transaction name for named smart contract', async () => { + contract = network.getContract('CHAINCODE_NAME', 'MY_CONTRACT'); + const result = await contract.newProposal('MY_TRANSACTION'); + + const proposal = assertDecodeProposal(result); + const argStrings = assertDecodeArgsAsStrings(proposal); + expect(argStrings[0]).toBe('MY_CONTRACT:MY_TRANSACTION'); + }); + + it('includes string arguments', async () => { + const expected = ['one', 'two', 'three']; + const result = await contract.newProposal('TRANSACTION_NAME', { + arguments: expected, + }); + + const proposal = assertDecodeProposal(result); + const argStrings = assertDecodeArgsAsStrings(proposal); + expect(argStrings.slice(1)).toStrictEqual(expected); + }); + + it('includes bytes arguments', async () => { + const expected = ['one', 'two', 'three']; + const args = expected.map((arg) => utf8Encoder.encode(arg)); + + const result = await contract.newProposal('TRANSACTION_NAME', { + arguments: args, + }); + + const proposal = assertDecodeProposal(result); + const argStrings = assertDecodeArgsAsStrings(proposal); + expect(argStrings.slice(1)).toStrictEqual(expected); + }); + + it('incldues bytes transient data', async () => { + const transientData = { + uno: new Uint8Array(utf8Encoder.encode('one')), + dos: new Uint8Array(utf8Encoder.encode('two')), + }; + const result = await contract.newProposal('TRANSACTION_NAME', { + transientData, + }); + + const proposal = assertDecodeProposal(result); + const payload = peer.ChaincodeProposalPayload.deserializeBinary(proposal.getPayload_asU8()); + + const actual = Object.fromEntries(payload.getTransientmapMap().getEntryList()); + + expect(actual).toEqual(transientData); + }); + + it('incldues string transient data', async () => { + const transientData = { + uno: 'one', + dos: 'two', + }; + const result = await contract.newProposal('TRANSACTION_NAME', { + transientData, + }); + + const proposal = assertDecodeProposal(result); + const payload = peer.ChaincodeProposalPayload.deserializeBinary(proposal.getPayload_asU8()); + + const actual = Object.fromEntries(payload.getTransientmapMap().getEntryList()); + const expected: Record = {}; + Object.entries(transientData).forEach(([k, v]) => (expected[k] = utf8Encoder.encode(v))); + + expect(actual).toEqual(expected); + }); + + it('sets endorsing orgs', async () => { + const result = await contract.newProposal('TRANSACTION_NAME', { + endorsingOrganizations: ['org1'], + }); + + const proposedTransaction = decodeProposedTransaction(result); + const actualOrgs = proposedTransaction.getEndorsingOrganizationsList(); + expect(actualOrgs).toStrictEqual(['org1']); + }); + + it('uses signer', async () => { + signer.mockResolvedValue(utf8Encoder.encode('MY_SIGNATURE')); + + const result = await contract.newProposal('TRANSACTION_NAME'); + + const proposedTransaction = decodeProposedTransaction(result); + const signature = proposedTransaction.getProposal()?.getSignature_asU8() ?? new Uint8Array(); + expect(utf8Decoder.decode(signature)).toBe('MY_SIGNATURE'); + }); + + it('uses identity', async () => { + const result = await contract.newProposal('TRANSACTION_NAME'); + + const proposal = assertDecodeProposal(result); + const signatureHeader = assertDecodeSignatureHeader(proposal); + + const expected = new msp.SerializedIdentity(); + expected.setMspid(identity.mspId); + expected.setIdBytes(identity.credentials); + + expect(signatureHeader.getCreator()).toEqual(expected.serializeBinary()); + }); + + it('includes transaction ID', async () => { + const result = await contract.newProposal('TRANSACTION_NAME'); + + const proposal = assertDecodeProposal(result); + const channelHeader = assertDecodeChannelHeader(proposal); + const proposalTransactionId = channelHeader.getTxId(); + + expect(proposalTransactionId).toHaveLength(64); // SHA-256 hash should be 32 bytes, which is 64 hex characters + expect(result.getTransactionId()).toStrictEqual(proposalTransactionId); + }); +}); diff --git a/web/src/proposal.ts b/web/src/proposal.ts new file mode 100644 index 000000000..aa46b952e --- /dev/null +++ b/web/src/proposal.ts @@ -0,0 +1,69 @@ +/* + * Copyright 2020 IBM All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ProposedTransaction } from '@hyperledger/fabric-protos/lib/gateway/gateway_pb'; +import { SignedProposal } from '@hyperledger/fabric-protos/lib/peer/proposal_pb'; +import { assertDefined } from './gateway'; +import { SigningIdentity } from './signingidentity'; + +/** + * Proposal represents a transaction proposal that can be sent to peers for endorsement or evaluated as a query. + */ +export interface Proposal { + /** + * Get the serialized bytes of the object. This is used to transfer the object state to a remote service. + */ + getBytes(): Uint8Array; + + /** + * Get the transaction ID for this proposal. + */ + getTransactionId(): string; +} + +export interface ProposalImplOptions { + signingIdentity: SigningIdentity; + proposedTransaction: ProposedTransaction; +} + +export class ProposalImpl implements Proposal { + readonly #signingIdentity: SigningIdentity; + readonly #proposedTransaction: ProposedTransaction; + readonly #proposal: SignedProposal; + + static async newInstance(options: Readonly): Promise { + const result = new ProposalImpl(options); + await result.#sign(); + return result; + } + + private constructor(options: Readonly) { + this.#signingIdentity = options.signingIdentity; + this.#proposedTransaction = options.proposedTransaction; + this.#proposal = assertDefined(options.proposedTransaction.getProposal(), 'Missing signed proposal'); + } + + getBytes(): Uint8Array { + return this.#proposedTransaction.serializeBinary(); + } + + getTransactionId(): string { + return this.#proposedTransaction.getTransactionId(); + } + + async #sign(): Promise { + const signature = await this.#signingIdentity.sign(this.#getMessage()); + this.#setSignature(signature); + } + + #getMessage(): Uint8Array { + return this.#proposal.getProposalBytes_asU8(); + } + + #setSignature(signature: Uint8Array): void { + this.#proposal.setSignature(signature); + } +} diff --git a/web/src/proposalbuilder.ts b/web/src/proposalbuilder.ts new file mode 100644 index 000000000..4a2c2ced3 --- /dev/null +++ b/web/src/proposalbuilder.ts @@ -0,0 +1,178 @@ +/* + * Copyright 2020 IBM All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { ChannelHeader, Header, HeaderType } from '@hyperledger/fabric-protos/lib/common/common_pb'; +import { ProposedTransaction } from '@hyperledger/fabric-protos/lib/gateway/gateway_pb'; +import { + ChaincodeID, + ChaincodeInput, + ChaincodeInvocationSpec, + ChaincodeSpec, +} from '@hyperledger/fabric-protos/lib/peer/chaincode_pb'; +import { + ChaincodeHeaderExtension, + ChaincodeProposalPayload, + Proposal as ProposalProto, + SignedProposal, +} from '@hyperledger/fabric-protos/lib/peer/proposal_pb'; +import { Timestamp } from 'google-protobuf/google/protobuf/timestamp_pb'; +import { Proposal, ProposalImpl } from './proposal'; +import { SigningIdentity } from './signingidentity'; +import { TransactionContext } from './transactioncontext'; + +/** + * Options used when evaluating or endorsing a transaction proposal. + */ +export interface ProposalOptions { + /** + * Arguments passed to the transaction function. + */ + arguments?: (string | Uint8Array)[]; + + /** + * Private data passed to the transaction function but not recorded on the ledger. + */ + transientData?: Record; + + /** + * Specifies the set of organizations that will attempt to endorse the proposal. + * No other organizations' peers will be sent this proposal. + * This is usually used in conjunction with transientData for private data scenarios. + */ + endorsingOrganizations?: string[]; +} + +export interface ProposalBuilderOptions extends ProposalOptions { + signingIdentity: SigningIdentity; + channelName: string; + chaincodeName: string; + transactionName: string; +} + +const utf8Encoder = new TextEncoder(); + +export class ProposalBuilder { + readonly #options: Readonly; + readonly #transactionContext: TransactionContext; + + static async newInstance(options: Readonly): Promise { + const transactionContext = await TransactionContext.newInstance(options.signingIdentity); + return new ProposalBuilder(options, transactionContext); + } + + private constructor(options: Readonly, transactionContext: TransactionContext) { + this.#options = options; + this.#transactionContext = transactionContext; + } + + build(): Promise { + return ProposalImpl.newInstance({ + signingIdentity: this.#options.signingIdentity, + proposedTransaction: this.#newProposedTransaction(), + }); + } + + #newProposedTransaction(): ProposedTransaction { + const result = new ProposedTransaction(); + result.setProposal(this.#newSignedProposal()); + result.setTransactionId(this.#transactionContext.getTransactionId()); + if (this.#options.endorsingOrganizations) { + result.setEndorsingOrganizationsList(this.#options.endorsingOrganizations); + } + return result; + } + + #newSignedProposal(): SignedProposal { + const result = new SignedProposal(); + result.setProposalBytes(this.#newProposal().serializeBinary()); + return result; + } + + #newProposal(): ProposalProto { + const result = new ProposalProto(); + result.setHeader(this.#newHeader().serializeBinary()); + result.setPayload(this.#newChaincodeProposalPayload().serializeBinary()); + return result; + } + + #newHeader(): Header { + const result = new Header(); + result.setChannelHeader(this.#newChannelHeader().serializeBinary()); + result.setSignatureHeader(this.#transactionContext.getSignatureHeader().serializeBinary()); + return result; + } + + #newChannelHeader(): ChannelHeader { + const result = new ChannelHeader(); + result.setType(HeaderType.ENDORSER_TRANSACTION); + result.setTxId(this.#transactionContext.getTransactionId()); + result.setTimestamp(Timestamp.fromDate(new Date())); + result.setChannelId(this.#options.channelName); + result.setExtension$(this.#newChaincodeHeaderExtension().serializeBinary()); + result.setEpoch(0); + return result; + } + + #newChaincodeHeaderExtension(): ChaincodeHeaderExtension { + const result = new ChaincodeHeaderExtension(); + result.setChaincodeId(this.#newChaincodeID()); + return result; + } + + #newChaincodeID(): ChaincodeID { + const result = new ChaincodeID(); + result.setName(this.#options.chaincodeName); + return result; + } + + #newChaincodeProposalPayload(): ChaincodeProposalPayload { + const result = new ChaincodeProposalPayload(); + result.setInput(this.#newChaincodeInvocationSpec().serializeBinary()); + const transientMap = result.getTransientmapMap(); + for (const [key, value] of Object.entries(this.#getTransientData())) { + transientMap.set(key, value); + } + return result; + } + + #newChaincodeInvocationSpec(): ChaincodeInvocationSpec { + const result = new ChaincodeInvocationSpec(); + result.setChaincodeSpec(this.#newChaincodeSpec()); + return result; + } + + #newChaincodeSpec(): ChaincodeSpec { + const result = new ChaincodeSpec(); + result.setType(ChaincodeSpec.Type.NODE); + result.setChaincodeId(this.#newChaincodeID()); + result.setInput(this.#newChaincodeInput()); + return result; + } + + #newChaincodeInput(): ChaincodeInput { + const result = new ChaincodeInput(); + result.setArgsList(this.#getArgsAsBytes()); + return result; + } + + #getArgsAsBytes(): Uint8Array[] { + return Array.of(this.#options.transactionName, ...(this.#options.arguments ?? [])).map(asBytes); + } + + #getTransientData(): Record { + const result: Record = {}; + + for (const [key, value] of Object.entries(this.#options.transientData ?? {})) { + result[key] = asBytes(value); + } + + return result; + } +} + +function asBytes(value: string | Uint8Array): Uint8Array { + return typeof value === 'string' ? utf8Encoder.encode(value) : value; +} diff --git a/web/src/signer.d.ts b/web/src/signer.d.ts new file mode 100644 index 000000000..c2b993da5 --- /dev/null +++ b/web/src/signer.d.ts @@ -0,0 +1,12 @@ +/* + * Copyright 2020 IBM All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +/** + * A signing implementation used to generate digital signatures from a supplied message. Note that a complete message + * is supplied, which might need to be hashed to create a digest before signing. + * @param message - Complete message bytes. + */ +export type Signer = (message: Uint8Array) => Promise; diff --git a/web/src/signingidentity.test.ts b/web/src/signingidentity.test.ts new file mode 100644 index 000000000..2b0337748 --- /dev/null +++ b/web/src/signingidentity.test.ts @@ -0,0 +1,94 @@ +/* + * Copyright 2020 IBM All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { msp } from '@hyperledger/fabric-protos'; +import { Identity, Signer } from '.'; +import { SigningIdentity } from './signingidentity'; + +const utf8Encoder = new TextEncoder(); +const utf8Decoder = new TextDecoder(); + +describe('SigningIdentity', () => { + const signature = 'SIGNATURE'; + + let identity: Identity; + let signer: Signer; + + beforeEach(() => { + identity = { + mspId: 'MSP_ID', + credentials: utf8Encoder.encode('CREDENTIALS'), + }; + signer = () => Promise.resolve(utf8Encoder.encode(signature)); + }); + + describe('identity', () => { + it('changes to returned identity do not modify signing identity', () => { + const expectedMspId = identity.mspId; + const expectedCredentials = Uint8Array.from(identity.credentials); // Copy + const signingIdentity = new SigningIdentity({ identity, signer }); + + const output = signingIdentity.getIdentity(); + output.mspId = 'wrong'; + output.credentials.fill(0); + + const actual = signingIdentity.getIdentity(); + expect(actual.mspId).toBe(expectedMspId); + const actualCredentials = Uint8Array.from(actual.credentials); // Ensure it's really a Uint8Array + expect(actualCredentials).toEqual(expectedCredentials); + }); + + it('changes to supplied identity do not modify signing identity', () => { + const expectedMspId = identity.mspId; + const expectedCredentials = Uint8Array.from(identity.credentials); // Copy + + const signingIdentity = new SigningIdentity({ identity, signer }); + identity.mspId = 'wrong'; + identity.credentials.fill(0); + + const actual = signingIdentity.getIdentity(); + expect(actual.mspId).toBe(expectedMspId); + const actualCredentials = Uint8Array.from(actual.credentials); // Ensure it's really a Uint8Array + expect(actualCredentials).toEqual(expectedCredentials); + }); + }); + + describe('creator', () => { + it('returns a valid SerializedIdentity protobuf', () => { + const signingIdentity = new SigningIdentity({ identity, signer }); + + const creator = signingIdentity.getCreator(); + + const actual = msp.SerializedIdentity.deserializeBinary(creator); + expect(actual.getMspid()).toBe(identity.mspId); + const credentials = Uint8Array.from(actual.getIdBytes_asU8()); // Ensure it's really a Uint8Array + expect(credentials).toEqual(identity.credentials); + }); + + it('changes to returned creator do not modify signing identity', () => { + const signingIdentity = new SigningIdentity({ identity, signer }); + const expected = Uint8Array.from(signingIdentity.getCreator()); // Ensure it's really a Uint8Array + + const creator = signingIdentity.getCreator(); + creator.fill(0); + + const actual = Uint8Array.from(signingIdentity.getCreator()); // Ensure it's really a Uint8Array + expect(actual).toEqual(expected); + }); + }); + + describe('signing', () => { + it('uses supplied signer', async () => { + const message = utf8Encoder.encode('MESSAGE'); + const signingIdentity = new SigningIdentity({ identity, signer }); + + const result = await signingIdentity.sign(message); + + const actual = utf8Decoder.decode(result); + expect(actual).toEqual(signature); + }); + }); +}); diff --git a/web/src/signingidentity.ts b/web/src/signingidentity.ts new file mode 100644 index 000000000..c6abd342d --- /dev/null +++ b/web/src/signingidentity.ts @@ -0,0 +1,47 @@ +/* + * Copyright 2020 IBM All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SerializedIdentity } from '@hyperledger/fabric-protos/lib/msp/identities_pb'; +import { ConnectOptions } from './gateway'; +import { Identity } from './identity'; +import { Signer } from './signer'; + +type SigningIdentityOptions = Pick; + +export class SigningIdentity { + readonly #identity: Identity; + readonly #creator: Uint8Array; + readonly #sign: Signer; + + constructor(options: Readonly) { + this.#identity = { + mspId: options.identity.mspId, + credentials: Uint8Array.from(options.identity.credentials), + }; + + const serializedIdentity = new SerializedIdentity(); + serializedIdentity.setMspid(options.identity.mspId); + serializedIdentity.setIdBytes(options.identity.credentials); + this.#creator = serializedIdentity.serializeBinary(); + + this.#sign = options.signer; + } + + getIdentity(): Identity { + return { + mspId: this.#identity.mspId, + credentials: Uint8Array.from(this.#identity.credentials), + }; + } + + getCreator(): Uint8Array { + return Uint8Array.from(this.#creator); + } + + sign(message: Uint8Array): Promise { + return this.#sign(message); + } +} diff --git a/web/src/transaction.test.ts b/web/src/transaction.test.ts new file mode 100644 index 000000000..fb64e0fd6 --- /dev/null +++ b/web/src/transaction.test.ts @@ -0,0 +1,158 @@ +/* + * Copyright 2020 IBM All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { common, gateway as gatewayproto, peer } from '@hyperledger/fabric-protos'; +import { Gateway, connect, Transaction } from '.'; +import { assertDefined } from './gateway'; +import { SigningIdentity } from './signingidentity'; +import { TransactionContext } from './transactioncontext'; + +const utf8Encoder = new TextEncoder(); +const utf8Decoder = new TextDecoder(); + +function newPreparedTransaction(options: { + context: TransactionContext; + result?: Uint8Array; +}): gatewayproto.PreparedTransaction { + const chaincodeResponse = new peer.Response(); + chaincodeResponse.setPayload(options.result ?? new Uint8Array()); + + const chaincodeAction = new peer.ChaincodeAction(); + chaincodeAction.setResponse(chaincodeResponse); + + const responsePayload = new peer.ProposalResponsePayload(); + responsePayload.setExtension$(chaincodeAction.serializeBinary()); + + const endorsedAction = new peer.ChaincodeEndorsedAction(); + endorsedAction.setProposalResponsePayload(responsePayload.serializeBinary()); + + const actionPayload = new peer.ChaincodeActionPayload(); + actionPayload.setAction(endorsedAction); + + const transactionAction = new peer.TransactionAction(); + transactionAction.setPayload(actionPayload.serializeBinary()); + + const transaction = new peer.Transaction(); + transaction.setActionsList([transactionAction]); + + const channelHeader = new common.ChannelHeader(); + channelHeader.setTxId(options.context.getTransactionId()); + + const header = new common.Header(); + header.setSignatureHeader(options.context.getSignatureHeader().serializeBinary()); + header.setChannelHeader(channelHeader.serializeBinary()); + + const payload = new common.Payload(); + payload.setData(transaction.serializeBinary()); + payload.setHeader(header); + + const envelope = new common.Envelope(); + envelope.setPayload(payload.serializeBinary()); + + const result = new gatewayproto.PreparedTransaction(); + result.setEnvelope(envelope); + result.setTransactionId(options.context.getTransactionId()); + + return result; +} + +function assertDecodeSignature(transaction: Transaction): Uint8Array { + const preparedTransaction = decodePreparedTransaction(transaction); + const envelope = assertDefined(preparedTransaction.getEnvelope(), 'envelope is undefined'); + return envelope.getSignature_asU8(); +} + +function decodePreparedTransaction(transaction: Transaction): gatewayproto.PreparedTransaction { + return gatewayproto.PreparedTransaction.deserializeBinary(transaction.getBytes()); +} + +describe('Transaction', () => { + let signingIdentity: SigningIdentity; + let signer: jest.Mock, Uint8Array[]>; + let gateway: Gateway; + let context: TransactionContext; + + beforeEach(async () => { + signer = jest.fn(undefined); + signer.mockResolvedValue(utf8Encoder.encode('SIGNATURE')); + + signingIdentity = new SigningIdentity({ + identity: { + mspId: 'MSP_ID', + credentials: utf8Encoder.encode('CERTIFICATE'), + }, + signer, + }); + + gateway = connect({ + identity: signingIdentity.getIdentity(), + signer, + }); + + context = await TransactionContext.newInstance(signingIdentity); + }); + + it('newTransaction throws on identity MSP ID mismatch', async () => { + const identity = signingIdentity.getIdentity(); + identity.mspId = 'WRONG_MSP_ID'; + context = await TransactionContext.newInstance(new SigningIdentity({ identity, signer })); + const preparedTransaction = newPreparedTransaction({ context }); + + const result = gateway.newTransaction(preparedTransaction.serializeBinary()); + + await expect(result).rejects.toBeDefined(); + }); + + it('newTransaction throws on identity credentials mismatch', async () => { + const identity = signingIdentity.getIdentity(); + identity.credentials = utf8Encoder.encode('WRONG_CREDENTIALS'); + context = await TransactionContext.newInstance(new SigningIdentity({ identity, signer })); + const preparedTransaction = newPreparedTransaction({ context }); + + const result = gateway.newTransaction(preparedTransaction.serializeBinary()); + + await expect(result).rejects.toBeDefined(); + }); + + it('uses signer', async () => { + signer.mockResolvedValue(utf8Encoder.encode('MY_SIGNATURE')); + const preparedTransaction = newPreparedTransaction({ context }); + + const result = await gateway.newTransaction(preparedTransaction.serializeBinary()); + + const signature = utf8Decoder.decode(assertDecodeSignature(result)); + expect(signature).toBe('MY_SIGNATURE'); + }); + + it('has correct transaction ID', async () => { + const preparedTransaction = newPreparedTransaction({ context }); + + const result = await gateway.newTransaction(preparedTransaction.serializeBinary()); + + expect(result.getTransactionId()).toBe(context.getTransactionId()); + }); + + it('uses transaction ID from signed content', async () => { + const preparedTransaction = newPreparedTransaction({ context }); + preparedTransaction.setTransactionId('WRONG_TRANSACTION_ID'); + + const result = await gateway.newTransaction(preparedTransaction.serializeBinary()); + + expect(result.getTransactionId()).toBe(context.getTransactionId()); + }); + + it('has correct result', async () => { + const preparedTransaction = newPreparedTransaction({ + context, + result: utf8Encoder.encode('MY_RESULT'), + }); + + const result = await gateway.newTransaction(preparedTransaction.serializeBinary()); + + const actual = utf8Decoder.decode(result.getResult()); + expect(actual).toEqual('MY_RESULT'); + }); +}); diff --git a/web/src/transaction.ts b/web/src/transaction.ts new file mode 100644 index 000000000..dcc029537 --- /dev/null +++ b/web/src/transaction.ts @@ -0,0 +1,94 @@ +/* + * Copyright 2020 IBM All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { PreparedTransaction } from '@hyperledger/fabric-protos/lib/gateway/gateway_pb'; +import { assertDefined } from './gateway'; +import { Identity } from './identity'; +import { SigningIdentity } from './signingidentity'; +import { parseTransactionEnvelope } from './transactionparser'; +import { Envelope } from '@hyperledger/fabric-protos/lib/common/common_pb'; + +/** + * Represents an endorsed transaction that can be submitted to the orderer for commit to the ledger. + */ +export interface Transaction { + /** + * Get the serialized bytes of the object. This is used to transfer the object state to a remote service. + */ + getBytes(): Uint8Array; + + /** + * Get the transaction result. This is obtained during the endorsement process when the transaction proposal is + * run on endorsing peers. + */ + getResult(): Uint8Array; + + /** + * Get the transaction ID. + */ + getTransactionId(): string; +} + +export interface TransactionImplOptions { + signingIdentity: SigningIdentity; + preparedTransaction: PreparedTransaction; +} + +export class TransactionImpl implements Transaction { + readonly #signingIdentity: SigningIdentity; + readonly #preparedTransaction: PreparedTransaction; + readonly #envelope: Envelope; + readonly #result: Uint8Array; + readonly #identity: Identity; + + static async newInstance(options: Readonly): Promise { + const result = new TransactionImpl(options); + await result.#sign(); + return result; + } + + private constructor(options: Readonly) { + this.#signingIdentity = options.signingIdentity; + this.#preparedTransaction = options.preparedTransaction; + + const envelope = assertDefined(options.preparedTransaction.getEnvelope(), 'Missing envelope'); + this.#envelope = envelope; + + const { identity, result, transactionId } = parseTransactionEnvelope(envelope); + this.#identity = identity; + this.#result = result; + this.#preparedTransaction.setTransactionId(transactionId); + } + + getBytes(): Uint8Array { + return this.#preparedTransaction.serializeBinary(); + } + + getResult(): Uint8Array { + return this.#result; + } + + getTransactionId(): string { + return this.#preparedTransaction.getTransactionId(); + } + + getIdentity(): Identity { + return this.#identity; + } + + #setSignature(signature: Uint8Array): void { + this.#envelope.setSignature(signature); + } + + async #sign(): Promise { + const signature = await this.#signingIdentity.sign(this.#getMessage()); + this.#setSignature(signature); + } + + #getMessage(): Uint8Array { + return this.#envelope.getPayload_asU8(); + } +} diff --git a/web/src/transactioncontext.ts b/web/src/transactioncontext.ts new file mode 100644 index 000000000..1a491d815 --- /dev/null +++ b/web/src/transactioncontext.ts @@ -0,0 +1,59 @@ +/* + * Copyright 2020 IBM All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { SignatureHeader } from '@hyperledger/fabric-protos/lib/common/common_pb'; +import { randomBytes, sha256 } from './crypto'; +import { SigningIdentity } from './signingidentity'; + +export class TransactionContext { + readonly #signatureHeader: SignatureHeader; + readonly #transactionId: string; + + static async newInstance(signingIdentity: SigningIdentity): Promise { + const nonce = randomBytes(24); + const creator = signingIdentity.getCreator(); + + const saltedCreator = concat(nonce, creator); + const rawTransactionId = await sha256(saltedCreator); + const transactionId = asHexString(rawTransactionId); + + const signatureHeader = new SignatureHeader(); + signatureHeader.setCreator(creator); + signatureHeader.setNonce(nonce); + + return new TransactionContext(transactionId, signatureHeader); + } + + private constructor(transactionId: string, signatureHeader: SignatureHeader) { + this.#transactionId = transactionId; + this.#signatureHeader = signatureHeader; + } + + getTransactionId(): string { + return this.#transactionId; + } + + getSignatureHeader(): SignatureHeader { + return this.#signatureHeader; + } +} + +function concat(...buffers: Uint8Array[]): Uint8Array { + const length = buffers.reduce((total, buffer) => total + buffer.byteLength, 0); + + const result = new Uint8Array(length); + let offset = 0; + buffers.forEach((buffer) => { + result.set(buffer, offset); + offset += buffer.byteLength; + }); + + return result; +} + +function asHexString(bytes: Uint8Array): string { + return Array.from(bytes, (n) => n.toString(16).padStart(2, '0')).join(''); +} diff --git a/web/src/transactionparser.ts b/web/src/transactionparser.ts new file mode 100644 index 000000000..eb4f505f5 --- /dev/null +++ b/web/src/transactionparser.ts @@ -0,0 +1,96 @@ +/* + * Copyright 2021 IBM All Rights Reserved. + * + * SPDX-License-Identifier: Apache-2.0 + */ + +import { + ChannelHeader, + Envelope, + Header, + Payload, + SignatureHeader, +} from '@hyperledger/fabric-protos/lib/common/common_pb'; +import { SerializedIdentity } from '@hyperledger/fabric-protos/lib/msp/identities_pb'; +import { ChaincodeAction } from '@hyperledger/fabric-protos/lib/peer/proposal_pb'; +import { ProposalResponsePayload } from '@hyperledger/fabric-protos/lib/peer/proposal_response_pb'; +import { + ChaincodeActionPayload, + Transaction, + TransactionAction, +} from '@hyperledger/fabric-protos/lib/peer/transaction_pb'; +import { assertDefined } from './gateway'; +import { Identity } from './identity'; + +export function parseTransactionEnvelope(envelope: Envelope): { + identity: Identity; + result: Uint8Array; + transactionId: string; +} { + const payload = Payload.deserializeBinary(envelope.getPayload_asU8()); + const header = assertDefined(payload.getHeader(), 'Missing header'); + const creator = parseCreatorFromHeader(header); + + return { + identity: { + mspId: creator.getMspid(), + credentials: creator.getIdBytes_asU8(), + }, + result: parseResultFromPayload(payload), + transactionId: parseTransactionIdFromHeader(header), + }; +} + +function parseTransactionIdFromHeader(header: Header): string { + const channelHeader = ChannelHeader.deserializeBinary(header.getChannelHeader_asU8()); + return channelHeader.getTxId(); +} + +function parseCreatorFromHeader(header: Header): SerializedIdentity { + const signatureHeader = SignatureHeader.deserializeBinary(header.getSignatureHeader_asU8()); + return SerializedIdentity.deserializeBinary(signatureHeader.getCreator_asU8()); +} + +function parseResultFromPayload(payload: Payload): Uint8Array { + const transaction = Transaction.deserializeBinary(payload.getData_asU8()); + + const errors: unknown[] = []; + + for (const transactionAction of transaction.getActionsList()) { + try { + return parseResultFromTransactionAction(transactionAction); + } catch (err) { + errors.push(err); + } + } + + throw Object.assign(new Error(`No proposal response found: ${asString(errors)}`), { + suppressed: errors, + }); +} + +function parseResultFromTransactionAction(transactionAction: TransactionAction): Uint8Array { + const actionPayload = ChaincodeActionPayload.deserializeBinary(transactionAction.getPayload_asU8()); + const endorsedAction = assertDefined(actionPayload.getAction(), 'Missing endorsed action'); + const responsePayload = ProposalResponsePayload.deserializeBinary(endorsedAction.getProposalResponsePayload_asU8()); + const chaincodeAction = ChaincodeAction.deserializeBinary(responsePayload.getExtension_asU8()); + const chaincodeResponse = assertDefined(chaincodeAction.getResponse(), 'Missing chaincode response'); + return chaincodeResponse.getPayload_asU8(); +} + +function asString(value: unknown): string { + if (typeof value === 'string') { + return `'${value}'`; + } + + if (Array.isArray(value)) { + if (value.length === 0) { + return '[]'; + } + + const contents = value.map(asString).join(', '); + return `[ ${contents} ]`; + } + + return String(value); +} diff --git a/web/tsconfig.build.json b/web/tsconfig.build.json new file mode 100644 index 000000000..bb9961fe1 --- /dev/null +++ b/web/tsconfig.build.json @@ -0,0 +1,8 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "extends": "./tsconfig.json", + "exclude": [ + "**/*.test.*", + "**/*.spec.*" + ] +} diff --git a/web/tsconfig.json b/web/tsconfig.json new file mode 100644 index 000000000..85fa97024 --- /dev/null +++ b/web/tsconfig.json @@ -0,0 +1,17 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "es2020", + "module": "esnext", + "moduleResolution": "bundler", + "esModuleInterop": true, + "declaration": true, + "outDir": "dist", + "noUnusedLocals": true, + "noImplicitReturns": true, + "forceConsistentCasingInFileNames": true, + "strict": true, + "skipLibCheck": true + }, + "include": ["src/"] +} diff --git a/web/typedoc.json b/web/typedoc.json new file mode 100644 index 000000000..d503142f9 --- /dev/null +++ b/web/typedoc.json @@ -0,0 +1,8 @@ +{ + "cleanOutputDir": true, + "entryPoints": [ + "src/index.ts" + ], + "out": "apidocs", + "treatWarningsAsErrors": true +}