From f2ed9040c035cf88b45e66d4b3a41eb082f08373 Mon Sep 17 00:00:00 2001
From: Ravin Perera <33562092+ravinsp@users.noreply.github.com>
Date: Fri, 11 Dec 2020 11:02:58 +0530
Subject: [PATCH] User protocol upgrade and js client lib. (#191)
* Unified js client lib for browser and nodejs.
* Client lib multiple connections support.
* Implemented server challenge response.
* Contract guid and version validation.
* Server key validation.
* User json message encoding improvements.
---
.../browser_client/hp-browser-client-lib.js | 294 -------
examples/browser_client/test.html | 16 -
examples/browser_client/test.js | 33 -
.../.dockerignore | 0
.../{nodejs_client => js_client}/.gitignore | 0
.../{nodejs_client => js_client}/Dockerfile | 0
examples/js_client/browser-test.html | 79 ++
.../client-build.sh | 0
.../client-start.sh | 0
.../file-client.js | 39 +-
examples/js_client/hp-client-lib.js | 729 ++++++++++++++++++
.../package-lock.json | 49 +-
examples/js_client/package.json | 17 +
.../text-client.js | 38 +-
examples/nodejs_client/.hp_client_keys | 1 -
examples/nodejs_client/hp-node-client-lib.js | 306 --------
examples/nodejs_client/multi-client.js | 73 --
examples/nodejs_client/package.json | 18 -
examples/nodejs_contract/echo_contract.js | 9 +-
examples/nodejs_contract/file_contract.js | 6 +-
examples/nodejs_contract/hp-contract-lib.js | 60 +-
src/conf.cpp | 44 +-
src/conf.hpp | 2 +
src/crypto.cpp | 19 +
src/crypto.hpp | 2 +
src/main.cpp | 2 +
src/msg/bson/usrmsg_bson.hpp | 2 -
src/msg/json/usrmsg_json.cpp | 229 ++++--
src/msg/json/usrmsg_json.hpp | 10 +-
src/msg/usrmsg_common.hpp | 9 +-
src/usr/usr.cpp | 13 +-
src/util/util.hpp | 4 +-
test/local-cluster/cluster-create.sh | 2 +-
test/vm-cluster/cluster.sh | 2 +-
34 files changed, 1202 insertions(+), 905 deletions(-)
delete mode 100644 examples/browser_client/hp-browser-client-lib.js
delete mode 100644 examples/browser_client/test.html
delete mode 100644 examples/browser_client/test.js
rename examples/{nodejs_client => js_client}/.dockerignore (100%)
rename examples/{nodejs_client => js_client}/.gitignore (100%)
rename examples/{nodejs_client => js_client}/Dockerfile (100%)
create mode 100644 examples/js_client/browser-test.html
rename examples/{nodejs_client => js_client}/client-build.sh (100%)
rename examples/{nodejs_client => js_client}/client-start.sh (100%)
rename examples/{nodejs_client => js_client}/file-client.js (92%)
create mode 100644 examples/js_client/hp-client-lib.js
rename examples/{nodejs_client => js_client}/package-lock.json (98%)
create mode 100644 examples/js_client/package.json
rename examples/{nodejs_client => js_client}/text-client.js (84%)
delete mode 100644 examples/nodejs_client/.hp_client_keys
delete mode 100644 examples/nodejs_client/hp-node-client-lib.js
delete mode 100644 examples/nodejs_client/multi-client.js
delete mode 100644 examples/nodejs_client/package.json
diff --git a/examples/browser_client/hp-browser-client-lib.js b/examples/browser_client/hp-browser-client-lib.js
deleted file mode 100644
index b83d7695..00000000
--- a/examples/browser_client/hp-browser-client-lib.js
+++ /dev/null
@@ -1,294 +0,0 @@
-window.HotPocket = (() => {
-
- const protocols = {
- json: "json"
- }
- Object.freeze(protocols);
-
- const events = {
- disconnect: "disconnect",
- contractOutput: "contractOutput",
- contractReadResponse: "contractReadResponse"
- }
- Object.freeze(events);
-
- const fromHexString = hexString =>
- new Uint8Array(hexString.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
-
- const toHexString = bytes =>
- bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, '0'), '');
-
- const KeyGenerator = {
- generate: function (privateKeyHex = null) {
-
- if (!privateKeyHex) {
- const keys = sodium.crypto_sign_keypair();
- return {
- privateKey: keys.privateKey,
- publicKey: keys.publicKey
- }
- }
- else {
- const binPrivateKey = fromHexString(privateKeyHex);
- return {
- privateKey: Uint8Array.from(binPrivateKey),
- publicKey: Uint8Array.from(binPrivateKey.slice(32))
- }
- }
- },
- }
-
- function EventEmitter() {
- const registrations = {};
-
- this.on = (eventName, listener) => {
- if (!registrations[eventName])
- registrations[eventName] = [];
- registrations[eventName].push(listener);
- }
-
- this.emit = (eventName, value) => {
- if (registrations[eventName])
- registrations[eventName].forEach(listener => listener(value));
- }
- }
-
- HotPocketClient = function HotPocketClient(contractId, server, keys) {
-
- let ws = null;
- const protocol = protocols.json; // We only support json in browser.
- const msgHelper = new MessageHelper(keys, protocol);
- const emitter = new EventEmitter();
-
- let handshakeResolver = null;
- let statResponseResolvers = [];
- let contractInputResolvers = {};
-
- this.connect = function () {
- return new Promise(resolve => {
-
- handshakeResolver = resolve;
-
- ws = new WebSocket(server);
-
- ws.addEventListener("close", () => {
- // If there are any ongoing resolvers resolve them with error output.
-
- handshakeResolver && handshakeResolver(false);
- handshakeResolver = null;
-
- statResponseResolvers.forEach(resolver => resolver(null));
- statResponseResolvers = [];
-
- Object.values(contractInputResolvers).forEach(resolver => resolver(null));
- contractInputResolvers = {};
-
- emitter.emit(events.disconnect);
- });
-
- ws.onmessage = async (rcvd) => {
-
- msg = await rcvd.data.text();
-
- try {
- m = msgHelper.deserializeMessage(msg);
- } catch (e) {
- console.log(e);
- console.log("Exception deserializing: ");
- console.log(msg);
- return;
- }
-
- if (m.type == 'handshake_challenge') {
- // Check whether contract id is matching if specified.
- if (contractId && m.contract_id != contractId) {
- console.error("Contract id mismatch.")
- ws.close();
- }
-
- // sign the challenge and send back the response
- const response = msgHelper.createHandshakeResponse(m.challenge);
- ws.send(JSON.stringify(response));
-
- setTimeout(() => {
- // If we are still connected, report handshaking as successful.
- // (If websocket disconnects, handshakeResolver will be null)
- handshakeResolver && handshakeResolver(true);
- handshakeResolver = null;
- }, 100);
- }
- else if (m.type == 'contract_read_response') {
- emitter.emit(events.contractReadResponse, msgHelper.deserializeOutput(m.content));
- }
- else if (m.type == 'contract_input_status') {
- const sigKey = (typeof m.input_sig === "string") ? m.input_sig : m.input_sig.toString("hex");
- const resolver = contractInputResolvers[sigKey];
- if (resolver) {
- if (m.status == "accepted")
- resolver("ok");
- else
- resolver(m.reason);
- delete contractInputResolvers[sigKey];
- }
- }
- else if (m.type == 'contract_output') {
- emitter.emit(events.contractOutput, msgHelper.deserializeOutput(m.content));
- }
- else if (m.type == "stat_response") {
- statResponseResolvers.forEach(resolver => {
- resolver({
- lcl: m.lcl,
- lclSeqNo: m.lcl_seqno
- });
- })
- statResponseResolvers = [];
- }
- else {
- console.log("Received unrecognized message: type:" + m.type);
- }
- }
- });
- };
-
- this.on = function (event, listener) {
- emitter.on(event, listener);
- }
-
- this.close = function () {
- return new Promise(resolve => {
- try {
- ws.addEventListener("close", resolve);
- ws.close();
- } catch (error) {
- resolve();
- }
- })
- }
-
- this.getStatus = function () {
- const p = new Promise(resolve => {
- statResponseResolvers.push(resolve);
- });
-
- // If this is the only awaiting stat request, then send an actual stat request.
- // Otherwise simply wait for the previously sent request.
- if (statResponseResolvers.length == 1) {
- const msg = msgHelper.createStatusRequest();
- ws.send(msgHelper.serializeObject(msg));
- }
- return p;
- }
-
- this.sendContractInput = async function (input, nonce = null, maxLclOffset = null) {
-
- if (!maxLclOffset)
- maxLclOffset = 10;
-
- if (!nonce)
- nonce = (new Date()).getTime().toString();
- else
- nonce = nonce.toString();
-
- // Acquire the current lcl and add the specified offset.
- const stat = await this.getStatus();
- if (!stat)
- return new Promise(resolve => resolve("ledger_status_error"));
- const maxLclSeqNo = stat.lclSeqNo + maxLclOffset;
-
- const msg = msgHelper.createContractInput(input, nonce, maxLclSeqNo);
- const sigKey = (typeof msg.sig === "string") ? msg.sig : msg.sig.toString("hex");
- const p = new Promise(resolve => {
- contractInputResolvers[sigKey] = resolve;
- });
-
- ws.send(msgHelper.serializeObject(msg));
- return p;
- }
-
- this.sendContractReadRequest = function (request) {
- const msg = msgHelper.createReadRequest(request);
- ws.send(msgHelper.serializeObject(msg));
- }
- }
-
- function MessageHelper(keys, protocol) {
-
- this.binaryEncode = function (data) {
- return toHexString(data);
- }
-
- this.serializeObject = function (obj) {
- return JSON.stringify(obj);
- }
-
- this.deserializeMessage = function (m) {
- return JSON.parse(m);
- }
-
- this.serializeInput = function (input) {
- return (typeof input === 'string' || input instanceof String) ? input : input.toString();
- }
-
- this.deserializeOutput = function (content) {
- return content;
- }
-
- this.createHandshakeResponse = function (challenge) {
- // For handshake response encoding Hot Pocket always uses json.
- // Handshake response will specify the protocol to use for subsequent messages.
- const sigBytes = sodium.crypto_sign_detached(challenge, keys.privateKey);
-
- return {
- type: "handshake_response",
- challenge: challenge,
- sig: toHexString(sigBytes),
- pubkey: "ed" + toHexString(keys.publicKey),
- protocol: protocol
- }
- }
-
- this.createContractInput = function (input, nonce, maxLclSeqNo) {
-
- if (input.length == 0)
- return null;
-
- const inpContainer = {
- input: this.serializeInput(input),
- nonce: nonce,
- max_lcl_seqno: maxLclSeqNo
- }
-
- const serlializedInpContainer = this.serializeObject(inpContainer);
- const sigBytes = sodium.crypto_sign_detached(serlializedInpContainer, keys.privateKey);
-
- const signedInpContainer = {
- type: "contract_input",
- input_container: serlializedInpContainer,
- sig: this.binaryEncode(sigBytes)
- }
-
- return signedInpContainer;
- }
-
- this.createReadRequest = function (request) {
-
- if (request.length == 0)
- return null;
-
- return {
- type: "contract_read_request",
- content: this.serializeInput(request)
- }
- }
-
- this.createStatusRequest = function () {
- return { type: 'stat' };
- }
- }
-
- return {
- KeyGenerator: KeyGenerator,
- Client: HotPocketClient,
- events: events,
- }
-})();
\ No newline at end of file
diff --git a/examples/browser_client/test.html b/examples/browser_client/test.html
deleted file mode 100644
index 6323e74c..00000000
--- a/examples/browser_client/test.html
+++ /dev/null
@@ -1,16 +0,0 @@
-
-
-
- HotPocket test page
-
-
-
-
-
-
- HotPocket browser client test page.
-
-
-
\ No newline at end of file
diff --git a/examples/browser_client/test.js b/examples/browser_client/test.js
deleted file mode 100644
index 845fef1a..00000000
--- a/examples/browser_client/test.js
+++ /dev/null
@@ -1,33 +0,0 @@
-window.sodium = {
- onload: async function () {
- const keys = HotPocket.KeyGenerator.generate(); // Can provide existing hex private key as parameter as well.
- const hpc = new HotPocket.Client(null, "wss://localhost:8081", keys);
-
- if (!await hpc.connect()) {
- console.log('Connection failed.');
- return;
- }
- console.log('HotPocket Connected.');
-
- // This will get fired if HP server disconnects unexpectedly.
- hpc.on(HotPocket.events.disconnect, () => {
- console.log('Server disconnected');
- })
-
- // This will get fired when contract sends an output.
- hpc.on(HotPocket.events.contractOutput, (output) => {
- console.log("Contract output>> " + output);
- })
-
- // This will get fired when contract sends a read response.
- hpc.on(HotPocket.events.contractReadResponse, (response) => {
- console.log("Contract read response>> " + response);
- })
-
- hpc.sendContractReadRequest("Hello");
- hpc.sendContractInput("World!")
-
- // When we need to close HP connection:
- // hpc.close();
- }
-};
\ No newline at end of file
diff --git a/examples/nodejs_client/.dockerignore b/examples/js_client/.dockerignore
similarity index 100%
rename from examples/nodejs_client/.dockerignore
rename to examples/js_client/.dockerignore
diff --git a/examples/nodejs_client/.gitignore b/examples/js_client/.gitignore
similarity index 100%
rename from examples/nodejs_client/.gitignore
rename to examples/js_client/.gitignore
diff --git a/examples/nodejs_client/Dockerfile b/examples/js_client/Dockerfile
similarity index 100%
rename from examples/nodejs_client/Dockerfile
rename to examples/js_client/Dockerfile
diff --git a/examples/js_client/browser-test.html b/examples/js_client/browser-test.html
new file mode 100644
index 00000000..4af813c0
--- /dev/null
+++ b/examples/js_client/browser-test.html
@@ -0,0 +1,79 @@
+
+
+
+ HotPocket test page
+
+
+
+
+
+
+
+
+
+ HotPocket browser client test page.
+
+
+
\ No newline at end of file
diff --git a/examples/nodejs_client/client-build.sh b/examples/js_client/client-build.sh
similarity index 100%
rename from examples/nodejs_client/client-build.sh
rename to examples/js_client/client-build.sh
diff --git a/examples/nodejs_client/client-start.sh b/examples/js_client/client-start.sh
similarity index 100%
rename from examples/nodejs_client/client-start.sh
rename to examples/js_client/client-start.sh
diff --git a/examples/nodejs_client/file-client.js b/examples/js_client/file-client.js
similarity index 92%
rename from examples/nodejs_client/file-client.js
rename to examples/js_client/file-client.js
index 325e8628..ef2006eb 100644
--- a/examples/nodejs_client/file-client.js
+++ b/examples/js_client/file-client.js
@@ -1,13 +1,12 @@
const fs = require('fs');
const readline = require('readline');
-const { exit } = require('process');
const bson = require('bson');
var path = require("path");
-const HotPocket = require('./hp-node-client-lib');
+const HotPocket = require('./hp-client-lib');
async function main() {
- const keys = await HotPocket.KeyGenerator.generate();
+ const keys = await HotPocket.generateKeys();
const pkhex = Buffer.from(keys.publicKey).toString('hex');
console.log('My public key is: ' + pkhex);
@@ -15,19 +14,32 @@ async function main() {
let server = 'wss://localhost:8080'
if (process.argv.length == 3) server = 'wss://localhost:' + process.argv[2]
if (process.argv.length == 4) server = 'wss://' + process.argv[2] + ':' + process.argv[3]
- const hpc = new HotPocket.Client(null, server, keys, HotPocket.protocols.bson);
+ const hpc = await HotPocket.createClient([server], keys, { protocol: HotPocket.protocols.bson });
// Establish HotPocket connection.
if (!await hpc.connect()) {
console.log('Connection failed.');
- exit();
+ return;
}
console.log('HotPocket Connected.');
+ // start listening for stdin
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout
+ });
+
+ // On ctrl + c we should close HP connection gracefully.
+ rl.on('SIGINT', () => {
+ console.log('SIGINT received...');
+ rl.close();
+ hpc.close();
+ });
+
// This will get fired if HP server disconnects unexpectedly.
hpc.on(HotPocket.events.disconnect, () => {
- console.log('Server diconnected');
- exit();
+ console.log('Disconnected');
+ rl.close();
})
// This will get fired when contract sends an output.
@@ -66,18 +78,7 @@ async function main() {
console.log("Unknown read request result.");
}
})
-
- // On ctrl + c we should close HP connection gracefully.
- process.once('SIGINT', function () {
- console.log('SIGINT received...');
- hpc.close();
- });
-
- // start listening for stdin
- const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout
- });
+
console.log("Ready to accept inputs.");
const input_pump = () => {
diff --git a/examples/js_client/hp-client-lib.js b/examples/js_client/hp-client-lib.js
new file mode 100644
index 00000000..c22fee1d
--- /dev/null
+++ b/examples/js_client/hp-client-lib.js
@@ -0,0 +1,729 @@
+(() => {
+
+ // Whether we are in Browser or NodeJs.
+ const isBrowser = !(typeof window === 'undefined');
+
+ // In browser, avoid duplicate initializations.
+ if (isBrowser && window.HotPocket)
+ return;
+
+ const supportedHpVersion = "0.0";
+ const serverChallengeSize = 16;
+ const connectionCheckIntervalMs = 1000;
+ const recentActivityThresholdMs = 3000;
+
+ // External dependency references.
+ let WebSocket = null;
+ let sodium = null;
+ let bson = null;
+
+ /*--- Included in public interface. ---*/
+ const protocols = {
+ json: "json",
+ bson: "bson" // (Requires nodejs or browserified hp client library on Browser)
+ }
+ Object.freeze(protocols);
+
+ /*--- Included in public interface. ---*/
+ const events = {
+ disconnect: "disconnect",
+ contractOutput: "contractOutput",
+ contractReadResponse: "contractReadResponse",
+ connectionChange: "connectionChange"
+ }
+ Object.freeze(events);
+
+ /*--- Included in public interface. ---*/
+ const generateKeys = async (privateKeyHex = null) => {
+
+ await initSodium();
+
+ if (!privateKeyHex) {
+ const keys = sodium.crypto_sign_keypair();
+ return {
+ privateKey: keys.privateKey,
+ publicKey: keys.publicKey
+ }
+ }
+ else {
+ const binPrivateKey = hexToUint8Array(privateKeyHex);
+ return {
+ privateKey: Uint8Array.from(binPrivateKey),
+ publicKey: Uint8Array.from(binPrivateKey.slice(32))
+ }
+ }
+ }
+
+ /*--- Included in public interface. ---*/
+ const createClient = async (servers, clientKeys, options) => {
+
+ const defaultOptions = {
+ contractId: null,
+ contractVersion: null,
+ validServerKeys: null,
+ protocol: protocols.json,
+ requiredConnectionCount: 1,
+ connectionTimeoutMs: 5000
+ };
+ const opt = options ? { ...defaultOptions, ...options } : defaultOptions;
+
+ if (!clientKeys)
+ throw "clientKeys not specified.";
+ if (opt.contractId == "")
+ throw "contractId not specified. Specify null to bypass contract id validation.";
+ if (opt.contractVersion == "")
+ throw "contractVersion not specified. Specify null to bypass contract version validation.";
+ if (!opt.protocol || (opt.protocol != protocols.json && opt.protocol != protocols.bson))
+ throw "Valid protocol not specified.";
+ if (!opt.requiredConnectionCount || opt.requiredConnectionCount == 0)
+ throw "requiredConnectionCount must be greater than 0.";
+ if (!opt.connectionTimeoutMs || opt.connectionTimeoutMs == 0)
+ throw "Connection timeout must be greater than 0.";
+
+ await initSodium();
+ initWebSocket();
+ if (opt.protocol == protocols.bson)
+ initBson();
+
+ // Load servers and serverKeys to object keys to avoid duplicates.
+
+ const serversLookup = {};
+ servers && servers.forEach(s => {
+ const url = s.trim();
+ if (url.length > 0)
+ serversLookup[url] = true
+ });
+ if (Object.keys(serversLookup).length == 0)
+ throw "servers not specified.";
+ if (opt.requiredConnectionCount > Object.keys(serversLookup).length)
+ throw "requiredConnectionCount is higher than no. of servers.";
+
+ let serverKeysLookup = null;
+ if (opt.validServerKeys) {
+ serverKeysLookup = {};
+ opt.validServerKeys.forEach(k => {
+ const key = k.trim();
+ if (key.length > 0)
+ serverKeysLookup[key] = true
+ });
+ }
+
+ if (serverKeysLookup && Object.keys(serverKeysLookup).length == 0)
+ throw "serverKeys must contain at least one key. Specify null to bypass key validation.";
+
+ return new HotPocketClient(opt.contractId, opt.contractVersion, clientKeys, serversLookup, serverKeysLookup, opt.protocol, opt.requiredConnectionCount, opt.connectionTimeoutMs);
+ }
+
+ function HotPocketClient(contractId, contractVersion, clientKeys, serversLookup, serverKeysLookup, protocol, requiredConnectionCount, connectionTimeoutMs) {
+
+ let emitter = new EventEmitter();
+
+ const nodes = Object.keys(serversLookup).map(s => {
+ return {
+ server: s, // Server address.
+ connection: null, // Hot Pocket connection (if any).
+ lastActivity: 0 // Last connection activity timestamp.
+ }
+ });
+
+ let status = 0; //0:none, 1:connected, 2:closed
+
+ // This will get fired whenever the required connection count gets fullfilled.
+ let initialConnectSuccess = null;
+
+ // Tracks when was the earliest time that we were missing some required connections.
+ // 0 indicates we are no missing any connections.
+ let connectionsMissingFrom = new Date().getTime();
+
+ // Checks for missing connections and attempts to establish them.
+ const reviewConnections = () => {
+
+ if (status == 2)
+ return;
+
+ // Check for connection changes periodically.
+ setTimeout(() => {
+ reviewConnections();
+ }, connectionCheckIntervalMs);
+
+ // Check whether we have fullfilled all required connections.
+ if (nodes.filter(n => n.connection && n.connection.isConnected()).length == requiredConnectionCount) {
+ connectionsMissingFrom = 0;
+ initialConnectSuccess && initialConnectSuccess(true);
+ initialConnectSuccess = null;
+ status = 1;
+ return;
+ }
+
+ if (connectionsMissingFrom == 0) {
+ // Reaching here means we moved from connections-fullfilled state to missing-connections state.
+ connectionsMissingFrom = new Date().getTime();
+ }
+ else if ((new Date().getTime() - connectionsMissingFrom) > connectionTimeoutMs) {
+
+ // This means we were not able to maintain required connection count for the entire timeout period.
+
+ console.log("Missing-connections timeout reached.");
+
+ // Close and cleanup all connections if we hit the timeout.
+ this.close().then(() => {
+ if (initialConnectSuccess) {
+ initialConnectSuccess(false);
+ initialConnectSuccess = null;
+ }
+ else {
+ emitter && emitter.emit(events.disconnect);
+ }
+ });
+ return;
+ }
+
+ // Reaching here means we should attempt to establish more connections if we have available slots.
+ let currentConnectionCount = nodes.filter(n => n.connection).length;
+ if (currentConnectionCount == requiredConnectionCount)
+ return;
+
+ // Find out available slots.
+ // Skip nodes that are already connected or is currently establishing connection.
+ // Skip nodes that have recently shown some connection activity.
+ // Give priority to nodes that have not shown any activity recently.
+ const freeNodes = nodes.filter(n => !n.connection && (new Date().getTime() - n.lastActivity) > recentActivityThresholdMs);
+ freeNodes.sort((a, b) => a.lastActivity - b.lastActivity); // Oldest activity comes first.
+
+ while (currentConnectionCount < requiredConnectionCount && freeNodes.length > 0) {
+
+ // Get the next available node.
+ const n = freeNodes.shift();
+ n.connection = new HotPocketConnection(contractId, contractVersion, clientKeys, n.server, serverKeysLookup, protocol, connectionTimeoutMs, emitter);
+ n.lastActivity = new Date().getTime();
+
+ n.connection.connect().then(success => {
+ if (!success)
+ n.connection = null;
+ else
+ emitter && emitter.emit(events.connectionChange, n.server, "add");
+ });
+
+ n.connection.onClose = () => {
+ n.connection = null;
+ emitter && emitter.emit(events.connectionChange, n.server, "remove");
+ };
+
+ currentConnectionCount++;
+ }
+ }
+
+ this.connect = () => {
+
+ if (status > 0)
+ return;
+
+ reviewConnections();
+ return new Promise(resolve => {
+ initialConnectSuccess = resolve;
+ })
+ }
+
+ this.close = async () => {
+
+ if (status == 2)
+ return;
+
+ status = 2;
+ emitter.clear(events.connectionChange);
+ emitter.clear(events.contractOutput);
+ emitter.clear(events.contractReadResponse);
+
+ // Close all nodes connections.
+ await Promise.all(nodes.filter(n => n.connection).map(n => n.connection.close()));
+ nodes.forEach(n => n.connection = null);
+ }
+
+ this.on = (event, listener) => {
+ emitter.on(event, listener);
+ }
+
+ this.sendContractInput = async (input, nonce = null, maxLclOffset = null) => {
+ if (status == 2)
+ return;
+
+ await Promise.all(
+ nodes.filter(n => n.connection && n.connection.isConnected())
+ .map(n => n.connection.sendContractInput(input, nonce, maxLclOffset)));
+ }
+
+ this.sendContractReadRequest = (request) => {
+ if (status == 2)
+ return;
+
+ nodes.filter(n => n.connection && n.connection.isConnected()).forEach(n => {
+ n.connection.sendContractReadRequest(request);
+ });
+ }
+ }
+
+ function HotPocketConnection(contractId, contractVersion, clientKeys, server, serverKeysLookup, protocol, connectionTimeoutMs, emitter) {
+
+ // Create message helper with JSON protocol initially.
+ // After challenge handshake, we will change this to use the protocol specified by user.
+ const msgHelper = new MessageHelper(clientKeys, protocols.json);
+
+ let connectionStatus = 0; // 0:none, 1:server challenge sent, 2:handshake complete.
+ let serverChallenge = null; // The hex challenge we have issued to the server.
+ let reportedContractId = null;
+ let reportedContractVersion = null;
+
+ let ws = null;
+ let handshakeTimer = null; // Timer to track connection handshake timeout.
+ let handshakeResolver = null;
+ let closeResolver = null;
+ let statResponseResolvers = [];
+ let contractInputResolvers = {};
+
+ const handshakeMessageHandler = (m) => {
+
+ if (connectionStatus == 0 && m.type == "user_challenge" && m.hp_version && m.contract_id) {
+
+ if (m.hp_version != supportedHpVersion) {
+ console.log(`Incompatible Hot Pocket server version. Expected:${supportedHpVersion} Got:${m.hp_version}`);
+ return false;
+ }
+ else if (!m.contract_id) {
+ console.log("Server did not specify contract id.");
+ return false;
+ }
+ else if (contractId && m.contract_id != contractId) {
+ console.log(`Contract id mismatch. Expected:${contractId} Got:${m.contract_id}`);
+ return false;
+ }
+ else if (!m.contract_version) {
+ console.log("Server did not specify contract version.");
+ return false;
+ }
+ else if (contractVersion && m.contract_version != contractVersion) {
+ console.log(`Contract version mismatch. Expected:${contractVersion} Got:${m.contract_version}`);
+ return false;
+ }
+
+ reportedContractId = m.contract_id;
+ reportedContractVersion = m.contract_version;
+
+ // Generate the challenge we are sending to server.
+ serverChallenge = uint8ArrayToHex(sodium.randombytes_buf(serverChallengeSize));
+
+ // Sign the challenge and send back the response
+ const response = msgHelper.createUserChallengeResponse(m.challenge, serverChallenge, protocol);
+ ws.send(msgHelper.serializeObject(response));
+
+ connectionStatus = 1;
+ return true;
+ }
+ else if (connectionStatus == 1 && serverChallenge && m.type == "server_challenge_response" && m.sig && m.pubkey) {
+
+ // If server keys has been specified, validate whether this server's pubkey is among the valid list.
+ if (serverKeysLookup && !serverKeysLookup[m.pubkey]) {
+ console.log(`${server} key '${m.pubkey}' not among the valid keys.`);
+ return false;
+ }
+
+ // Verify server challenge response.
+ const stringToVerify = serverChallenge + reportedContractId + reportedContractVersion;
+ const serverPubkeyHex = m.pubkey.substring(2); // Skip 'ed' prefix;
+ if (!sodium.crypto_sign_verify_detached(hexToUint8Array(m.sig), stringToVerify, hexToUint8Array(serverPubkeyHex))) {
+ console.log(`${server} challenge response verification failed.`);
+ return false;
+ }
+
+ clearTimeout(handshakeTimer); // Cancel the handshake timeout monitor.
+ handshakeTimer = null;
+ serverChallenge = null; // Clear the sent challenge as we no longer need it.
+ msgHelper.useProtocol(protocol); // Here onwards, use the message protocol specified by user.
+ connectionStatus = 2; // Handshake complete.
+
+ // If we are still connected, report handshaking as successful.
+ // (If websocket disconnects, handshakeResolver will be already null)
+ handshakeResolver && handshakeResolver(true);
+ console.log(`Connected to ${server}`);
+ return true;
+ }
+
+ console.log(`${server} invalid message during handshake. Connection status:${connectionStatus}`);
+ console.log(m);
+ return false;
+ }
+
+ const contractMessageHandler = (m) => {
+
+ if (m.type == "contract_read_response") {
+ emitter && emitter.emit(events.contractReadResponse, msgHelper.deserializeOutput(m.content));
+ }
+ else if (m.type == "contract_input_status") {
+ const sigKey = msgHelper.stringifySignature(m.input_sig);
+ const resolver = contractInputResolvers[sigKey];
+ if (resolver) {
+ if (m.status == "accepted")
+ resolver("ok");
+ else
+ resolver(m.reason);
+ delete contractInputResolvers[sigKey];
+ }
+ }
+ else if (m.type == "contract_output") {
+ emitter && emitter.emit(events.contractOutput, msgHelper.deserializeOutput(m.content));
+ }
+ else if (m.type == "stat_response") {
+ statResponseResolvers.forEach(resolver => {
+ resolver({
+ lcl: m.lcl,
+ lclSeqNo: m.lcl_seqno
+ });
+ })
+ statResponseResolvers = [];
+ }
+ else {
+ console.log("Received unrecognized contract message: type:" + m.type);
+ return false;
+ }
+
+ return true;
+ }
+
+ const messageHandler = async (rcvd) => {
+
+ const data = (connectionStatus < 2 || protocol == protocols.json) ?
+ (isBrowser ? await rcvd.data.text() : rcvd.data) :
+ (isBrowser ? await rcvd.data.arrayBuffer() : rcvd.data);
+
+ try {
+ m = msgHelper.deserializeMessage(data);
+ }
+ catch (e) {
+ console.log(e);
+ console.log("Exception deserializing: ");
+ console.log(data || rcvd);
+
+ // If we get invalid message during handshake, close the socket.
+ if (connectionStatus < 2)
+ this.close();
+
+ return;
+ }
+
+ let isValid = false;
+ if (connectionStatus < 2)
+ isValid = handshakeMessageHandler(m);
+ else if (connectionStatus == 2)
+ isValid = contractMessageHandler(m);
+
+ if (!isValid) {
+ // If we get invalid message during handshake, close the socket.
+ if (connectionStatus < 2)
+ this.close();
+ }
+ }
+
+ const openHandler = () => {
+ ws.addEventListener("message", messageHandler);
+ ws.addEventListener("close", closeHandler);
+
+ handshakeTimer = setTimeout(() => {
+ // If handshake does not complete within timeout, close the connection.
+ this.close();
+ handshakeTimer = null;
+ }, connectionTimeoutMs);
+ }
+
+ const closeHandler = () => {
+
+ if (closeResolver)
+ console.log("Closing connection to " + server);
+ else
+ console.log("Disconnected from " + server);
+
+ emitter = null;
+
+ if (handshakeTimer)
+ clearTimeout(handshakeTimer);
+
+ // If there are any ongoing resolvers resolve them with error output.
+
+ handshakeResolver && handshakeResolver(false);
+ handshakeResolver = null;
+
+ statResponseResolvers.forEach(resolver => resolver(null));
+ statResponseResolvers = [];
+
+ Object.values(contractInputResolvers).forEach(resolver => resolver(null));
+ contractInputResolvers = {};
+
+ this.onClose && this.onClose();
+ closeResolver && closeResolver();
+ }
+
+ const errorHandler = (e) => {
+ handshakeResolver && handshakeResolver(false);
+ }
+
+ this.isConnected = () => {
+ return connectionStatus == 2;
+ };
+
+ this.connect = () => {
+ console.log("Connecting to " + server);
+ return new Promise(resolve => {
+
+ ws = isBrowser ? new WebSocket(server) : new WebSocket(server, { rejectUnauthorized: false });
+ handshakeResolver = resolve;
+ ws.addEventListener("error", errorHandler);
+ ws.addEventListener("open", openHandler);
+ });
+ }
+
+ this.close = () => {
+ if (ws.readyState == WebSocket.OPEN) {
+ return new Promise(resolve => {
+ closeResolver = resolve;
+ ws.close();
+ });
+ }
+ else {
+ return Promise.resolve();
+ }
+ }
+
+ this.getStatus = () => {
+
+ if (connectionStatus != 2)
+ return Promise.resolve(null);
+
+ const p = new Promise(resolve => {
+ statResponseResolvers.push(resolve);
+ });
+
+ // If this is the only awaiting stat request, then send an actual stat request.
+ // Otherwise simply wait for the previously sent request.
+ if (statResponseResolvers.length == 1) {
+ const msg = msgHelper.createStatusRequest();
+ ws.send(msgHelper.serializeObject(msg));
+ }
+ return p;
+ }
+
+ this.sendContractInput = async (input, nonce = null, maxLclOffset = null) => {
+
+ if (connectionStatus != 2)
+ return null;
+
+ if (!maxLclOffset)
+ maxLclOffset = 10;
+
+ if (!nonce)
+ nonce = (new Date()).getTime().toString();
+ else
+ nonce = nonce.toString();
+
+ // Acquire the current lcl and add the specified offset.
+ const stat = await this.getStatus();
+ if (!stat)
+ return new Promise(resolve => resolve("ledger_status_error"));
+ const maxLclSeqNo = stat.lclSeqNo + maxLclOffset;
+
+ const msg = msgHelper.createContractInput(input, nonce, maxLclSeqNo);
+ const sigKey = msgHelper.stringifySignature(msg.sig);
+ const p = new Promise(resolve => {
+ contractInputResolvers[sigKey] = resolve;
+ });
+
+ ws.send(msgHelper.serializeObject(msg));
+ return p;
+ }
+
+ this.sendContractReadRequest = (request) => {
+
+ if (connectionStatus != 2)
+ return;
+
+ const msg = msgHelper.createReadRequest(request);
+ ws.send(msgHelper.serializeObject(msg));
+ }
+ }
+
+ function MessageHelper(keys, protocol) {
+
+ this.useProtocol = (p) => {
+ protocol = p;
+ }
+
+ this.binaryEncode = (data) => {
+ return protocol == protocols.json ?
+ uint8ArrayToHex(data) :
+ (Buffer.isBuffer(data) ? data : Buffer.from(data));
+ }
+
+ this.serializeObject = (obj) => {
+ return protocol == protocols.json ? JSON.stringify(obj) : bson.serialize(obj);
+ }
+
+ this.deserializeMessage = (m) => {
+ return protocol == protocols.json ? JSON.parse(m) : bson.deserialize(m);
+ }
+
+ this.serializeInput = (input) => {
+ return protocol == protocols.json ?
+ ((typeof input === "string" || input instanceof String) ? input : input.toString()) :
+ (Buffer.isBuffer(input) ? input : Buffer.from(input));
+ }
+
+ this.deserializeOutput = (content) => {
+ return protocol == protocols.json ? content : content.buffer;
+ }
+
+ // Used for generating strings to hold signature as js object keys.
+ this.stringifySignature = (sig) => {
+ if (typeof sig === 'string' || sig instanceof String)
+ return sig;
+ else if (sig instanceof Uint8Array)
+ return uint8ArrayToHex(sig);
+ else if (sig.buffer) // BSON binary.
+ return uint8ArrayToHex(new Uint8Array(sig.buffer));
+ else
+ throw "Cannot stringify signature.";
+ }
+
+ this.createUserChallengeResponse = (userChallenge, serverChallenge, msgProtocol) => {
+ // For challenge response encoding Hot Pocket always uses json.
+ // Challenge response will specify the protocol to use for contract messages.
+ const sigBytes = sodium.crypto_sign_detached(userChallenge, keys.privateKey);
+
+ return {
+ type: "user_challenge_response",
+ sig: this.binaryEncode(sigBytes),
+ pubkey: "ed" + this.binaryEncode(keys.publicKey),
+ server_challenge: serverChallenge,
+ protocol: msgProtocol
+ }
+ }
+
+ this.createContractInput = (input, nonce, maxLclSeqNo) => {
+
+ if (input.length == 0)
+ return null;
+
+ const inpContainer = {
+ input: this.serializeInput(input),
+ nonce: nonce,
+ max_lcl_seqno: maxLclSeqNo
+ }
+
+ const serlializedInpContainer = this.serializeObject(inpContainer);
+ const sigBytes = sodium.crypto_sign_detached(serlializedInpContainer, keys.privateKey);
+
+ const signedInpContainer = {
+ type: "contract_input",
+ input_container: serlializedInpContainer,
+ sig: this.binaryEncode(sigBytes)
+ }
+
+ return signedInpContainer;
+ }
+
+ this.createReadRequest = (request) => {
+ if (request.length == 0)
+ return null;
+
+ return {
+ type: "contract_read_request",
+ content: this.serializeInput(request)
+ }
+ }
+
+ this.createStatusRequest = () => {
+ return { type: "stat" };
+ }
+ }
+
+ function hexToUint8Array(hexString) {
+ return new Uint8Array(hexString.match(/.{1,2}/g).map(byte => parseInt(byte, 16)));
+ }
+
+ function uint8ArrayToHex(bytes) {
+ return bytes.reduce((str, byte) => str + byte.toString(16).padStart(2, "0"), "");
+ }
+
+ function EventEmitter() {
+ const registrations = {};
+
+ this.on = (eventName, listener) => {
+ if (!registrations[eventName])
+ registrations[eventName] = [];
+ registrations[eventName].push(listener);
+ }
+
+ this.emit = (eventName, ...value) => {
+ if (registrations[eventName])
+ registrations[eventName].forEach(listener => listener(...value));
+ }
+
+ this.clear = (eventName) => {
+ if (eventName)
+ delete registrations[eventName]
+ else
+ Object.keys(registrations).forEach(k => delete registrations[k]);
+ }
+ }
+
+ // Set sodium reference.
+ async function initSodium() {
+ if (sodium) // If already set, do nothing.
+ return;
+ else if (isBrowser && window.sodium) { // If no parameter specified, try to get from window.sodium.
+ sodium = window.sodium;
+ }
+ else if (isBrowser && !window.sodium) { // If sodium not yet loaded in browser, wait for sodium ready.
+ await new Promise(resolve => {
+ window.sodium = {
+ onload: async function (sodiumRef) {
+ sodium = sodiumRef;
+ resolve();
+ }
+ }
+ })
+ }
+ else if (!isBrowser) { // nodejs
+ sodium = require('libsodium-wrappers');
+ await sodium.ready;
+ }
+ }
+
+ // Set bson reference.
+ function initBson() {
+ if (bson) // If already set, do nothing.
+ return;
+ else if (isBrowser && window.BSON) // If no parameter specified, try to get from window.BSON.
+ bson = window.BSON;
+ else if (!isBrowser) // nodejs
+ bson = require('bson');
+ }
+
+ // Set WebSocket reference.
+ function initWebSocket() {
+ if (WebSocket) // If already set, do nothing.
+ return;
+ else if (isBrowser && window.WebSocket) // If no parameter specified, try to get from window.WebSocket.
+ WebSocket = window.WebSocket;
+ else if (!isBrowser) // nodejs
+ WebSocket = require('ws');
+ }
+
+ const hotPocketLib = {
+ generateKeys,
+ createClient,
+ events,
+ protocols
+ }
+
+ if (isBrowser)
+ window.HotPocket = hotPocketLib;
+ else
+ module.exports = hotPocketLib;
+})();
\ No newline at end of file
diff --git a/examples/nodejs_client/package-lock.json b/examples/js_client/package-lock.json
similarity index 98%
rename from examples/nodejs_client/package-lock.json
rename to examples/js_client/package-lock.json
index ddcde86f..41c7b036 100644
--- a/examples/nodejs_client/package-lock.json
+++ b/examples/js_client/package-lock.json
@@ -160,9 +160,9 @@
"dev": true
},
"base64-js": {
- "version": "1.3.1",
- "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.3.1.tgz",
- "integrity": "sha512-mLQ4i2QO1ytvGWFWmcngKO//JXAQueZvwEKtjgQFM4jIK0kU+ytMfplL8j+n5mspOfjHwoAg+9yhb7BwAHm36g=="
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/base64-js/-/base64-js-1.5.1.tgz",
+ "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA=="
},
"bn.js": {
"version": "5.1.3",
@@ -430,12 +430,12 @@
}
},
"buffer": {
- "version": "5.6.0",
- "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.6.0.tgz",
- "integrity": "sha512-/gDYp/UtU0eA1ys8bOs9J6a+E/KWIY+DZ+Q2WESNUA0jFRsJOc0SNUO6xJ5SGA1xueg3NL65W6s+NY5l9cunuw==",
+ "version": "5.7.1",
+ "resolved": "https://registry.npmjs.org/buffer/-/buffer-5.7.1.tgz",
+ "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==",
"requires": {
- "base64-js": "^1.0.2",
- "ieee754": "^1.1.4"
+ "base64-js": "^1.3.1",
+ "ieee754": "^1.1.13"
}
},
"buffer-from": {
@@ -454,6 +454,7 @@
"version": "4.0.1",
"resolved": "https://registry.npmjs.org/bufferutil/-/bufferutil-4.0.1.tgz",
"integrity": "sha512-xowrxvpxojqkagPcWRQVXZl0YXhRhAtBEIq3VoER1NH5Mw1n1o0ojdspp+GS2J//2gCVyrzQDApQ4unGF+QOoA==",
+ "dev": true,
"requires": {
"node-gyp-build": "~3.7.0"
}
@@ -1330,9 +1331,9 @@
"dev": true
},
"ieee754": {
- "version": "1.1.13",
- "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.1.13.tgz",
- "integrity": "sha512-4vf7I2LYV/HaWerSo3XmlMkp5eZ83i+/CDluXi/IGTs/O1sejBNhTtnxzmRZfvOUqj7lZjqHkeTvpgSFDlWZTg=="
+ "version": "1.2.1",
+ "resolved": "https://registry.npmjs.org/ieee754/-/ieee754-1.2.1.tgz",
+ "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA=="
},
"inflight": {
"version": "1.0.6",
@@ -1378,10 +1379,13 @@
}
},
"is-arguments": {
- "version": "1.0.4",
- "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.0.4.tgz",
- "integrity": "sha512-xPh0Rmt8NE65sNzvyUmWgI1tz3mKq74lGA0mL8LYZcoIzKOzDh6HmrYm3d18k60nHerC8A9Km8kYu87zfSFnLA==",
- "dev": true
+ "version": "1.1.0",
+ "resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.1.0.tgz",
+ "integrity": "sha512-1Ij4lOMPl/xB5kBDn7I+b2ttPMKa8szhEIrXDuXQD/oe3HJLTLhqhgGspwgyGd6MOywBUqVvYicF72lkgDnIHg==",
+ "dev": true,
+ "requires": {
+ "call-bind": "^1.0.0"
+ }
},
"is-buffer": {
"version": "1.1.6",
@@ -1434,11 +1438,6 @@
"integrity": "sha1-u5NdSFgsuhaMBoNJV6VKPgcSTxE=",
"dev": true
},
- "isomorphic-ws": {
- "version": "4.0.1",
- "resolved": "https://registry.npmjs.org/isomorphic-ws/-/isomorphic-ws-4.0.1.tgz",
- "integrity": "sha512-BhBvN2MBpWTaSHdWRb/bwdZJ1WaehQ2L1KngkCkfLUGF0mAWAT1sQUQacEmQ0jXkFw/czDXPNQSL5u2/Krsz1w=="
- },
"json-stable-stringify": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/json-stable-stringify/-/json-stable-stringify-0.0.1.tgz",
@@ -1662,9 +1661,9 @@
}
},
"ms": {
- "version": "2.1.2",
- "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.2.tgz",
- "integrity": "sha512-sGkPx+VjMtmA6MX27oA4FBFELFCZZ4S4XqeGOXCv68tT+jb3vk/RyaKWP0PTKyWtmLSM0b+adUTEvbs1PEaH2w==",
+ "version": "2.1.3",
+ "resolved": "https://registry.npmjs.org/ms/-/ms-2.1.3.tgz",
+ "integrity": "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==",
"dev": true
},
"multi-stage-sourcemap": {
@@ -1720,7 +1719,8 @@
"node-gyp-build": {
"version": "3.7.0",
"resolved": "https://registry.npmjs.org/node-gyp-build/-/node-gyp-build-3.7.0.tgz",
- "integrity": "sha512-L/Eg02Epx6Si2NXmedx+Okg+4UHqmaf3TNcxd50SF9NQGcJaON3AtU++kax69XV7YWz4tUspqZSAsVofhFKG2w=="
+ "integrity": "sha512-L/Eg02Epx6Si2NXmedx+Okg+4UHqmaf3TNcxd50SF9NQGcJaON3AtU++kax69XV7YWz4tUspqZSAsVofhFKG2w==",
+ "dev": true
},
"object-assign": {
"version": "4.1.1",
@@ -2491,6 +2491,7 @@
"version": "5.0.2",
"resolved": "https://registry.npmjs.org/utf-8-validate/-/utf-8-validate-5.0.2.tgz",
"integrity": "sha512-SwV++i2gTD5qh2XqaPzBnNX88N6HdyhQrNNRykvcS0QKvItV9u3vPEJr+X5Hhfb1JC0r0e1alL0iB09rY8+nmw==",
+ "dev": true,
"requires": {
"node-gyp-build": "~3.7.0"
}
diff --git a/examples/js_client/package.json b/examples/js_client/package.json
new file mode 100644
index 00000000..50d51b0e
--- /dev/null
+++ b/examples/js_client/package.json
@@ -0,0 +1,17 @@
+{
+ "scripts": {
+ "build-node": "browserify --node -p tinyify hp-client-lib.js -o dist/hp-node-client-lib.js",
+ "build-browser": "browserify -p tinyify hp-client-lib.js -o dist/hp-browser-client-lib.js"
+ },
+ "dependencies": {
+ "libsodium-wrappers": "0.7.6",
+ "ws": "7.1.2",
+ "bson": "4.0.4"
+ },
+ "devDependencies": {
+ "tinyify": "3.0.0",
+ "browserify": "16.5.2",
+ "utf-8-validate": "5.0.2",
+ "bufferutil": "4.0.1"
+ }
+}
diff --git a/examples/nodejs_client/text-client.js b/examples/js_client/text-client.js
similarity index 84%
rename from examples/nodejs_client/text-client.js
rename to examples/js_client/text-client.js
index 9d678676..04af9ad1 100644
--- a/examples/nodejs_client/text-client.js
+++ b/examples/js_client/text-client.js
@@ -1,9 +1,9 @@
const readline = require('readline');
-const { exit } = require('process');
-const HotPocket = require('./hp-node-client-lib');
+const HotPocket = require('./hp-client-lib');
async function main() {
- const keys = await HotPocket.KeyGenerator.generate();
+
+ const keys = await HotPocket.generateKeys();
const pkhex = Buffer.from(keys.publicKey).toString('hex');
console.log('My public key is: ' + pkhex);
@@ -11,19 +11,32 @@ async function main() {
let server = 'wss://localhost:8080'
if (process.argv.length == 3) server = 'wss://localhost:' + process.argv[2]
if (process.argv.length == 4) server = 'wss://' + process.argv[2] + ':' + process.argv[3]
- const hpc = new HotPocket.Client(null, server, keys, HotPocket.protocols.json);
+ const hpc = await HotPocket.createClient([server], keys);
// Establish HotPocket connection.
if (!await hpc.connect()) {
console.log('Connection failed.');
- exit();
+ return;
}
console.log('HotPocket Connected.');
+ // start listening for stdin
+ const rl = readline.createInterface({
+ input: process.stdin,
+ output: process.stdout
+ });
+
+ // On ctrl + c we should close HP connection gracefully.
+ rl.on('SIGINT', () => {
+ console.log('SIGINT received...');
+ rl.close();
+ hpc.close();
+ });
+
// This will get fired if HP server disconnects unexpectedly.
hpc.on(HotPocket.events.disconnect, () => {
- console.log('Server disconnected');
- exit();
+ console.log('Disconnected');
+ rl.close();
})
// This will get fired when contract sends an output.
@@ -36,17 +49,6 @@ async function main() {
console.log("Contract read response>> " + response);
})
- // On ctrl + c we should close HP connection gracefully.
- process.once('SIGINT', function () {
- console.log('SIGINT received...');
- hpc.close();
- });
-
- // start listening for stdin
- const rl = readline.createInterface({
- input: process.stdin,
- output: process.stdout
- });
console.log("Ready to accept inputs.");
const input_pump = () => {
diff --git a/examples/nodejs_client/.hp_client_keys b/examples/nodejs_client/.hp_client_keys
deleted file mode 100644
index b09c0fde..00000000
--- a/examples/nodejs_client/.hp_client_keys
+++ /dev/null
@@ -1 +0,0 @@
-{"publicKey":"705bf26354ee4c63c0e5d5d883c07cefc3196d049bd3825f827eb3bc23ead035","privateKey":"07ce6f7d6f0da38d5956ecc4ea1d18de77fc8bded089eb52199a46ffe2098c88705bf26354ee4c63c0e5d5d883c07cefc3196d049bd3825f827eb3bc23ead035","keyType":"ed25519"}
\ No newline at end of file
diff --git a/examples/nodejs_client/hp-node-client-lib.js b/examples/nodejs_client/hp-node-client-lib.js
deleted file mode 100644
index 0078ffe3..00000000
--- a/examples/nodejs_client/hp-node-client-lib.js
+++ /dev/null
@@ -1,306 +0,0 @@
-const WebSocket = require('isomorphic-ws');
-const sodium = require('libsodium-wrappers');
-const EventEmitter = require('events');
-const bson = require('bson');
-
-// Whether we are in NodeJS or Browser.
-const isNodeJS = (typeof window === 'undefined');
-
-const protocols = {
- json: "json",
- bson: "bson"
-}
-Object.freeze(protocols);
-
-const events = {
- disconnect: "disconnect",
- contractOutput: "contractOutput",
- contractReadResponse: "contractReadResponse"
-}
-Object.freeze(events);
-
-const HotPocketKeyGenerator = {
- generate: async function (privateKeyHex = null) {
- await sodium.ready;
-
- if (!privateKeyHex) {
- const keys = sodium.crypto_sign_keypair();
- return {
- privateKey: keys.privateKey,
- publicKey: keys.publicKey
- }
- }
- else {
- const binPrivateKey = Buffer.from(privateKeyHex, "hex");
- return {
- privateKey: Uint8Array.from(binPrivateKey),
- publicKey: Uint8Array.from(binPrivateKey.slice(32))
- }
- }
- },
-}
-
-function HotPocketClient(contractId, server, keys, protocol = protocols.json) {
-
- let ws = null;
- const msgHelper = new MessageHelper(keys, protocol);
- const emitter = new EventEmitter();
-
- let handshakeResolver = null;
- let statResponseResolvers = [];
- let contractInputResolvers = {};
-
- this.connect = function () {
- return new Promise(resolve => {
-
- handshakeResolver = resolve;
-
- if (isNodeJS) {
- ws = new WebSocket(server, {
- rejectUnauthorized: false
- })
- }
- else {
- ws = new WebSocket(server);
- }
-
- ws.onclose = () => {
-
- // If there are any ongoing resolvers resolve them with error output.
-
- handshakeResolver && handshakeResolver(false);
- handshakeResolver = null;
-
- statResponseResolvers.forEach(resolver => resolver(null));
- statResponseResolvers = [];
-
- Object.values(contractInputResolvers).forEach(resolver => resolver(null));
- contractInputResolvers = {};
-
- emitter.emit(events.disconnect);
- };
-
- ws.onmessage = async (rcvd) => {
-
- if (isNodeJS) {
- msg = rcvd.data;
- }
- else {
- msg = (handshakeResolver || protocol == protocols.json) ?
- await rcvd.data.text() :
- Buffer.from(await rcvd.data.arrayBuffer());
- }
-
- try {
- // Use JSON if we are still in handshake phase.
- m = handshakeResolver ? JSON.parse(msg) : msgHelper.deserializeMessage(msg);
- } catch (e) {
- console.log(e);
- console.log("Exception deserializing: ");
- console.log(msg)
- return;
- }
-
- if (m.type == 'handshake_challenge') {
- // Check whether contract id is matching if specified.
- if (contractId && m.contract_id != contractId) {
- console.error("Contract id mismatch.")
- ws.close();
- }
-
- // Sign the challenge and send back the response
- const response = msgHelper.createHandshakeResponse(m.challenge);
- ws.send(JSON.stringify(response));
-
- setTimeout(() => {
- // If we are still connected, report handshaking as successful.
- // (If websocket disconnects, handshakeResolver will be null)
- handshakeResolver && handshakeResolver(true);
- handshakeResolver = null;
- }, 100);
- }
- else if (m.type == 'contract_read_response') {
- emitter.emit(events.contractReadResponse, msgHelper.deserializeOutput(m.content));
- }
- else if (m.type == 'contract_input_status') {
- const sigKey = (typeof m.input_sig === "string") ? m.input_sig : m.input_sig.toString("hex");
- const resolver = contractInputResolvers[sigKey];
- if (resolver) {
- if (m.status == "accepted")
- resolver("ok");
- else
- resolver(m.reason);
- delete contractInputResolvers[sigKey];
- }
- }
- else if (m.type == 'contract_output') {
- emitter.emit(events.contractOutput, msgHelper.deserializeOutput(m.content));
- }
- else if (m.type == "stat_response") {
- statResponseResolvers.forEach(resolver => {
- resolver({
- lcl: m.lcl,
- lclSeqNo: m.lcl_seqno
- });
- })
- statResponseResolvers = [];
- }
- else {
- console.log("Received unrecognized message: type:" + m.type);
- }
- }
- });
- };
-
- this.on = function (event, listener) {
- emitter.on(event, listener);
- }
-
- this.close = function () {
- return new Promise(resolve => {
- try {
- ws.onclose = resolve;
- ws.on("close", resolve);
- ws.close();
- } catch (error) {
- resolve();
- }
- })
- }
-
- this.getStatus = function () {
- const p = new Promise(resolve => {
- statResponseResolvers.push(resolve);
- });
-
- // If this is the only awaiting stat request, then send an actual stat request.
- // Otherwise simply wait for the previously sent request.
- if (statResponseResolvers.length == 1) {
- const msg = msgHelper.createStatusRequest();
- ws.send(msgHelper.serializeObject(msg));
- }
- return p;
- }
-
- this.sendContractInput = async function (input, nonce = null, maxLclOffset = null) {
-
- if (!maxLclOffset)
- maxLclOffset = 10;
-
- if (!nonce)
- nonce = (new Date()).getTime().toString();
- else
- nonce = nonce.toString();
-
- // Acquire the current lcl and add the specified offset.
- const stat = await this.getStatus();
- if (!stat)
- return new Promise(resolve => resolve("ledger_status_error"));
- const maxLclSeqNo = stat.lclSeqNo + maxLclOffset;
-
- const msg = msgHelper.createContractInput(input, nonce, maxLclSeqNo);
- const sigKey = (typeof msg.sig === "string") ? msg.sig : msg.sig.toString("hex");
- const p = new Promise(resolve => {
- contractInputResolvers[sigKey] = resolve;
- });
-
- ws.send(msgHelper.serializeObject(msg));
- return p;
- }
-
- this.sendContractReadRequest = function (request) {
- const msg = msgHelper.createReadRequest(request);
- ws.send(msgHelper.serializeObject(msg));
- }
-}
-
-function MessageHelper(keys, protocol) {
-
- this.binaryEncode = function (data) {
- const buffer = Buffer.isBuffer(data) ? data : Buffer.from(data);
- return protocol == protocols.json ? buffer.toString("hex") : buffer;
- }
-
- this.serializeObject = function (obj) {
- return protocol == protocols.json ? JSON.stringify(obj) : bson.serialize(obj);
- }
-
- this.deserializeMessage = function (m) {
- return protocol == protocols.json ? JSON.parse(m) : bson.deserialize(m);
- }
-
- this.serializeInput = function (input) {
- return protocol == protocols.json ?
- input.toString() :
- Buffer.isBuffer(input) ? input : Buffer.from(input);
- }
-
- this.deserializeOutput = function (content) {
- return (protocol == protocols.json) ? content : content.buffer;
- }
-
- this.createHandshakeResponse = function (challenge) {
- // For handshake response encoding Hot Pocket always uses json.
- // Handshake response will specify the protocol to use for subsequent messages.
- const sigBytes = sodium.crypto_sign_detached(challenge, keys.privateKey);
- return {
- type: "handshake_response",
- challenge: challenge,
- sig: Buffer.from(sigBytes).toString("hex"),
- pubkey: "ed" + Buffer.from(keys.publicKey).toString("hex"),
- protocol: protocol
- }
- }
-
- this.createContractInput = function (input, nonce, maxLclSeqNo) {
-
- if (input.length == 0)
- return null;
-
- const inpContainer = {
- input: this.serializeInput(input),
- nonce: nonce,
- max_lcl_seqno: maxLclSeqNo
- }
-
- const serlializedInpContainer = this.serializeObject(inpContainer);
- const sigBytes = sodium.crypto_sign_detached(Buffer.from(serlializedInpContainer), keys.privateKey);
-
- const signedInpContainer = {
- type: "contract_input",
- input_container: serlializedInpContainer,
- sig: this.binaryEncode(sigBytes)
- }
-
- return signedInpContainer;
- }
-
- this.createReadRequest = function (request) {
-
- if (request.length == 0)
- return null;
-
- return {
- type: "contract_read_request",
- content: this.serializeInput(request)
- }
- }
-
- this.createStatusRequest = function () {
- return { type: 'stat' };
- }
-}
-
-const exportObj = {
- KeyGenerator: HotPocketKeyGenerator,
- Client: HotPocketClient,
- events: events,
- protocols: protocols
-};
-
-if (isNodeJS) {
- module.exports = exportObj;
-}
-else {
- window.HotPocket = exportObj;
-}
\ No newline at end of file
diff --git a/examples/nodejs_client/multi-client.js b/examples/nodejs_client/multi-client.js
deleted file mode 100644
index 0cebb334..00000000
--- a/examples/nodejs_client/multi-client.js
+++ /dev/null
@@ -1,73 +0,0 @@
-const { HotPocketClient, HotPocketKeyGenerator, HotPocketEvents } = require('./hp-node-client-lib');
-
-async function main() {
-
- const clientCount = 3;
- const clients = [];
- for (let i = 1; i <= clientCount; i++) {
- clients.push(new RoboClient('wss://localhost:', 8081, i.toString()));
- }
-
- await Promise.all(clients.map(c => c.connect()));
- console.log("Clients connected.");
-
- await Promise.all(clients.map(c => c.sendInputs(["A", "B", "C"])));
- console.log("Clients submitted.");
-
- // await Promise.all(clients.map(c => c.disconnect()));
- // console.log("Clients closed.");
-}
-
-function RoboClient(server, port, clientId) {
-
- this.connect = async () => {
- this.keys = await HotPocketKeyGenerator.generate();
- this.hpclient = new HotPocketClient(null, server + port, this.keys);
-
-
- if (!await this.hpclient.connect()) {
- this.log('Connection failed.');
- }
- this.log('HotPocket Connected.');
-
- // This will get fired if HP server disconnects unexpectedly.
- this.hpclient.on(HotPocketEvents.disconnect, () => {
- this.log('Server disconnected');
- })
-
- // This will get fired when contract sends an output.
- this.hpclient.on(HotPocketEvents.contractOutput, (output) => {
- this.log("Contract output>> " + Buffer.from(output, "hex"));
- })
-
- // This will get fired when contract sends a read response.
- this.hpclient.on(HotPocketEvents.contractReadResponse, (response) => {
- this.log("Contract read response>> " + Buffer.from(response, "hex"));
- })
- }
-
- this.disconnect = async () => {
- await this.hpclient.close();
- }
-
- this.sendInputs = async (inputs) => {
-
- let idx = 1;
- let tasks = [];
- inputs.forEach(inp => {
- const nonce = clientId.toString() + '-' + idx.toString();
- tasks.push(this.hpclient.sendContractInput((clientId + inp), nonce).then(submissionStatus => {
- if (submissionStatus && submissionStatus != "ok")
- this.log("Input submission failed. reason: " + submissionStatus);
- }));
- idx++;
- })
- await Promise.all(tasks);
- }
-
- this.log = (text) => {
- console.log(clientId + ": " + text)
- }
-}
-
-main();
\ No newline at end of file
diff --git a/examples/nodejs_client/package.json b/examples/nodejs_client/package.json
deleted file mode 100644
index 279fefa2..00000000
--- a/examples/nodejs_client/package.json
+++ /dev/null
@@ -1,18 +0,0 @@
-{
- "scripts": {
- "buildnode": "browserify --node -p tinyify hp-node-client-lib.js -o dist/hp-node-client-lib.js",
- "buildbrowser": "browserify -p tinyify hp-node-client-lib.js -o dist/hp-browsercompat-client-lib.js"
- },
- "dependencies": {
- "libsodium-wrappers": "0.7.6",
- "ws": "7.1.2",
- "isomorphic-ws": "4.0.1",
- "bson": "4.0.4",
- "utf-8-validate": "5.0.2",
- "bufferutil": "4.0.1"
- },
- "devDependencies": {
- "browserify": "16.5.2",
- "tinyify": "3.0.0"
- }
-}
diff --git a/examples/nodejs_contract/echo_contract.js b/examples/nodejs_contract/echo_contract.js
index afd582dc..9ead7d55 100644
--- a/examples/nodejs_contract/echo_contract.js
+++ b/examples/nodejs_contract/echo_contract.js
@@ -1,4 +1,4 @@
-const { HotPocketContract } = require("./hp-contract-lib");
+const HotPocket = require("./hp-contract-lib");
const fs = require('fs');
// HP smart contract is defined as a function which takes HP ExecutionContext as an argument.
@@ -24,10 +24,7 @@ const echoContract = async (ctx) => {
const msg = buf.toString();
const output = (msg == "ts") ? fs.readFileSync("exects.txt").toString() : ("Echoing: " + msg);
-
- // Stringify to escape JSON characters and remove surrounding double quotes.
- const stringified = JSON.stringify(output);
- await user.send(stringified.substr(1, stringified.length - 2));
+ await user.send(output);
resolve();
}));
@@ -53,5 +50,5 @@ const echoContract = async (ctx) => {
// }
}
-const hpc = new HotPocketContract();
+const hpc = new HotPocket.Contract();
hpc.init(echoContract);
\ No newline at end of file
diff --git a/examples/nodejs_contract/file_contract.js b/examples/nodejs_contract/file_contract.js
index 5fcd51bc..782e9c54 100644
--- a/examples/nodejs_contract/file_contract.js
+++ b/examples/nodejs_contract/file_contract.js
@@ -1,4 +1,4 @@
-const { HotPocketContract } = require("./hp-contract-lib");
+const HotPocket = require("./hp-contract-lib");
const fs = require('fs');
const bson = require('bson');
@@ -76,5 +76,5 @@ const fileContract = async (ctx) => {
}
};
-const hpc = new HotPocketContract();
-hpc.init(fileContract);
+const hpc = new HotPocket.Contract();
+hpc.init(fileContract, HotPocket.clientProtocols.bson);
diff --git a/examples/nodejs_contract/hp-contract-lib.js b/examples/nodejs_contract/hp-contract-lib.js
index 5ea377d3..4c729d4a 100644
--- a/examples/nodejs_contract/hp-contract-lib.js
+++ b/examples/nodejs_contract/hp-contract-lib.js
@@ -3,21 +3,31 @@ const tty = require('tty');
require('process');
const MAX_SEQ_PACKET_SIZE = 128 * 1024;
-const CONTROL_MESSAGE = {
- CONTRACT_END: "contract_end",
- UNL_CHANGESET: "unl_changeset"
+const controlMessages = {
+ contractEnd: "contract_end",
+ unlChangeset: "unl_changeset"
}
-Object.freeze(CONTROL_MESSAGE);
+Object.freeze(controlMessages);
+
+const clientProtocols = {
+ json: "json",
+ bson: "bson"
+}
+Object.freeze(clientProtocols);
class HotPocketContract {
#controlChannel = null;
+ #clientProtocol = null;
- init(contractFunc) {
+ init(contractFunc, clientProtocol = clientProtocols.json) {
if (this.#controlChannel) // Already initialized.
return false;
+ this.#clientProtocol = clientProtocol;
+
+ // Check whether we are running on a console and provide error.
if (tty.isatty(process.stdin.fd)) {
console.error("Error: Hot Pocket smart contracts must be executed via Hot Pocket.");
return false;
@@ -37,7 +47,7 @@ class HotPocketContract {
const pendingTasks = [];
const nplChannel = new NplChannel(hpargs.nplfd);
- const users = new UsersCollection(hpargs.userinfd, hpargs.users);
+ const users = new UsersCollection(hpargs.userinfd, hpargs.users, this.#clientProtocol);
const peers = new PeersCollection(hpargs.readonly, hpargs.unl, nplChannel, pendingTasks);
const executionContext = new ContractExecutionContext(hpargs, users, peers, this.#controlChannel);
@@ -51,7 +61,7 @@ class HotPocketContract {
}
#terminate = () => {
- this.#controlChannel.send({ type: CONTROL_MESSAGE.CONTRACT_END });
+ this.#controlChannel.send({ type: controlMessages.contractEnd });
this.#controlChannel.close();
}
}
@@ -72,7 +82,7 @@ class ContractExecutionContext {
async updateUnl(addArray, removeArray) {
if (this.readonly)
throw "UNL update not allowed in readonly mode."
- await this.#controlChannel.send({ type: CONTROL_MESSAGE.UNL_CHANGESET, add: addArray, remove: removeArray });
+ await this.#controlChannel.send({ type: controlMessages.unlChangeset, add: addArray, remove: removeArray });
}
}
@@ -81,7 +91,7 @@ class UsersCollection {
#users = {};
#infd = null;
- constructor(userInputsFd, usersObj) {
+ constructor(userInputsFd, usersObj, clientProtocol) {
this.#infd = userInputsFd;
Object.entries(usersObj).forEach(([pubKey, arr]) => {
@@ -89,7 +99,7 @@ class UsersCollection {
const outfd = arr[0]; // First array element is the output fd.
arr.splice(0, 1); // Remove first element (output fd). The rest are pairs of msg offset/length tuples.
- const channel = new UserChannel(outfd);
+ const channel = new UserChannel(outfd, clientProtocol);
this.#users[pubKey] = new User(pubKey, channel, arr);
});
}
@@ -134,18 +144,41 @@ class User {
class UserChannel {
#outfd = -1;
+ #clientProtocol = null;
- constructor(outfd) {
+ constructor(outfd, clientProtocol) {
this.#outfd = outfd;
+ this.#clientProtocol = clientProtocol;
}
send(msg) {
- const messageBuf = Buffer.from(msg);
+ const messageBuf = this.serialize(msg);
let headerBuf = Buffer.alloc(4);
// Writing message length in big endian format.
headerBuf.writeUInt32BE(messageBuf.byteLength)
return writevAsync(this.#outfd, [headerBuf, messageBuf]);
}
+
+ serialize(msg) {
+
+ if (!msg)
+ throw "Cannot serialize null content.";
+
+ if (Buffer.isBuffer(msg))
+ return msg;
+
+ if (this.#clientProtocol == clientProtocols.bson) {
+ return Buffer.from(msg);
+ }
+ else { // json
+
+ // In JSON, we need to ensure that the final buffer contains a string.
+ if (typeof msg === "string" || msg instanceof String)
+ return Buffer.from(msg);
+ else
+ return Buffer.from(JSON.stringify(msg));
+ }
+ }
}
class PeersCollection {
@@ -299,5 +332,6 @@ const invokeCallback = async (callback, ...args) => {
const errHandler = (err) => console.log(err);
module.exports = {
- HotPocketContract
+ Contract: HotPocketContract,
+ clientProtocols
}
\ No newline at end of file
diff --git a/src/conf.cpp b/src/conf.cpp
index 5e71ee5b..f0f57af3 100644
--- a/src/conf.cpp
+++ b/src/conf.cpp
@@ -96,13 +96,9 @@ namespace conf
crypto::generate_signing_keys(cfg.pubkey, cfg.seckey);
binpair_to_hex(cfg);
- // Generate contract id hex.
- std::string rand_string;
- crypto::random_bytes(rand_string, 16);
- util::bin2hex(
- cfg.contractid,
- reinterpret_cast(rand_string.data()),
- rand_string.length());
+ cfg.hpversion = util::HP_VERSION;
+ cfg.contractversion = "1.0";
+ cfg.contractid = crypto::generate_uuid();
//Add self pubkey to the unl.
cfg.unl.emplace(cfg.pubkey);
@@ -221,21 +217,21 @@ namespace conf
}
ifs.close();
- // Check whether the contract version is specified.
- std::string_view cfgversion = d["version"].as();
- if (cfgversion.empty())
+ // Check whether the hp version is specified.
+ cfg.hpversion = d["hpversion"].as();
+ if (cfg.hpversion.empty())
{
- std::cerr << "Contract config version missing.\n";
+ std::cerr << "Contract config HP version missing.\n";
return -1;
}
- // Check whether this contract complies with the min version requirement.
- int verresult = util::version_compare(std::string(cfgversion), std::string(util::MIN_CONTRACT_VERSION));
+ // Check whether this config complies with the min version requirement.
+ int verresult = util::version_compare(cfg.hpversion, std::string(util::MIN_CONFIG_VERSION));
if (verresult == -1)
{
- std::cerr << "Contract version too old. Minimum "
- << util::MIN_CONTRACT_VERSION << " required. "
- << cfgversion << " found.\n";
+ std::cerr << "Config version too old. Minimum "
+ << util::MIN_CONFIG_VERSION << " required. "
+ << cfg.hpversion << " found.\n";
return -1;
}
else if (verresult == -2)
@@ -244,9 +240,18 @@ namespace conf
return -1;
}
- // Load up the values into the struct.
-
cfg.contractid = d["contractid"].as();
+ cfg.contractversion = d["contractversion"].as();
+ if (cfg.contractid.empty())
+ {
+ std::cerr << "Contract id not specified.\n";
+ return -1;
+ }
+ else if (cfg.contractversion.empty())
+ {
+ std::cerr << "Contract version not specified.\n";
+ return -1;
+ }
if (d["mode"] == MODE_OBSERVER)
cfg.operating_mode = OPERATING_MODE::OBSERVER;
@@ -372,8 +377,9 @@ namespace conf
// Popualte json document with 'cfg' values.
// ojson is used instead of json to preserve insertion order.
jsoncons::ojson d;
- d.insert_or_assign("version", util::HP_VERSION);
+ d.insert_or_assign("hpversion", cfg.hpversion);
d.insert_or_assign("contractid", cfg.contractid);
+ d.insert_or_assign("contractversion", cfg.contractversion);
d.insert_or_assign("mode", cfg.operating_mode == OPERATING_MODE::OBSERVER ? MODE_OBSERVER : MODE_PROPOSER);
d.insert_or_assign("pubkeyhex", cfg.pubkeyhex);
diff --git a/src/conf.hpp b/src/conf.hpp
index 23d12f65..ed1de697 100644
--- a/src/conf.hpp
+++ b/src/conf.hpp
@@ -84,7 +84,9 @@ namespace conf
bool is_unl = false; // Indicate whether we are a unl node or not.
// Config elements which are loaded from the config file.
+ std::string hpversion; // Version of Hot Pocket that generated the config.
std::string contractid; // Contract guid.
+ std::string contractversion; // Contract version string.
OPERATING_MODE operating_mode = OPERATING_MODE::OBSERVER; // Configured startup operating mode of the contract (Observer/Proposer).
std::string pubkeyhex; // Contract hex public key
std::string seckeyhex; // Contract hex secret key
diff --git a/src/crypto.cpp b/src/crypto.cpp
index 2a81a7f6..21a4dc08 100644
--- a/src/crypto.cpp
+++ b/src/crypto.cpp
@@ -227,4 +227,23 @@ namespace crypto
return hash;
}
+ std::string generate_uuid()
+ {
+ std::string rand_bytes;
+ random_bytes(rand_bytes, 16);
+
+ // Set bits for UUID v4 variant 1.
+ uint8_t *uuid = (uint8_t *)rand_bytes.data();
+ uuid[6] = (uuid[8] & 0x0F) | 0x40;
+ uuid[8] = (uuid[8] & 0xBF) | 0x80;
+
+ std::string hex;
+ util::bin2hex(
+ hex,
+ reinterpret_cast(rand_bytes.data()),
+ rand_bytes.length());
+
+ return hex.substr(0, 8) + "-" + hex.substr(8, 4) + "-" + hex.substr(12, 4) + "-" + hex.substr(16, 4) + "-" + hex.substr(20);
+ }
+
} // namespace crypto
\ No newline at end of file
diff --git a/src/crypto.hpp b/src/crypto.hpp
index 87d9afa1..95745aa9 100644
--- a/src/crypto.hpp
+++ b/src/crypto.hpp
@@ -39,6 +39,8 @@ namespace crypto
std::string get_hash(const std::vector &sw_vect);
+ std::string generate_uuid();
+
} // namespace crypto
#endif
\ No newline at end of file
diff --git a/src/main.cpp b/src/main.cpp
index 8723c474..5857f920 100644
--- a/src/main.cpp
+++ b/src/main.cpp
@@ -195,9 +195,11 @@ int main(int argc, char **argv)
hplog::init();
+ LOG_INFO << "Hot Pocket " << util::HP_VERSION;
LOG_INFO << "Operating mode: "
<< (conf::cfg.operating_mode == conf::OPERATING_MODE::OBSERVER ? "Observer" : "Proposer");
LOG_INFO << "Public key: " << conf::cfg.pubkeyhex.substr(2); // Public key without 'ed' prefix.
+ LOG_INFO << "Contract: " << conf::cfg.contractid << " (" << conf::cfg.contractversion << ")";
if (ledger::init() == -1 ||
unl::init() == -1 ||
diff --git a/src/msg/bson/usrmsg_bson.hpp b/src/msg/bson/usrmsg_bson.hpp
index f697ee2b..abdb4cf0 100644
--- a/src/msg/bson/usrmsg_bson.hpp
+++ b/src/msg/bson/usrmsg_bson.hpp
@@ -6,8 +6,6 @@
namespace msg::usrmsg::bson
{
- void create_user_challenge(std::vector &msg, std::string &challengehex);
-
void create_status_response(std::vector &msg, const uint64_t lcl_seq_no, std::string_view lcl);
void create_contract_input_status(std::vector &msg, std::string_view status, std::string_view reason,
diff --git a/src/msg/json/usrmsg_json.cpp b/src/msg/json/usrmsg_json.cpp
index 13386aa0..1e895f48 100644
--- a/src/msg/json/usrmsg_json.cpp
+++ b/src/msg/json/usrmsg_json.cpp
@@ -29,41 +29,84 @@ namespace msg::usrmsg::json
* @param msg String reference to copy the generated json message string into.
* Message format:
* {
- * "type": "handshake_challenge",
+ * "hp_version": "",
+ * "type": "user_challenge",
* "contract_id": "",
- * "challenge": ""
+ * "contract_version": "",
+ * "challenge": ""
* }
- * @param challengehex String reference to copy the generated hex challenge string into.
+ * @param challenge_bytes String reference to copy the generated challenge bytes into.
*/
- void create_user_challenge(std::vector &msg, std::string &challengehex)
+ void create_user_challenge(std::vector &msg, std::string &challenge)
{
- // Use libsodium to generate the random challenge bytes.
- unsigned char challenge_bytes[msg::usrmsg::CHALLENGE_LEN];
- randombytes_buf(challenge_bytes, msg::usrmsg::CHALLENGE_LEN);
-
- // We pass the hex challenge string separately to the caller even though
- // we also include it in the challenge msg as well.
-
- util::bin2hex(challengehex, challenge_bytes, msg::usrmsg::CHALLENGE_LEN);
+ std::string challenge_bytes;
+ crypto::random_bytes(challenge_bytes, msg::usrmsg::CHALLENGE_LEN);
+ util::bin2hex(challenge,
+ reinterpret_cast(challenge_bytes.data()),
+ msg::usrmsg::CHALLENGE_LEN);
// Construct the challenge msg json.
- // We do not use jasoncons library here in favour of performance because this is a simple json message.
+ // We do not use jsoncons library here in favour of performance because this is a simple json message.
// Since we know the rough size of the challenge message we reserve adequate amount for the holder.
// Only Hot Pocket version number is variable length.
msg.reserve(256);
msg += "{\"";
+ msg += msg::usrmsg::FLD_HP_VERSION;
+ msg += SEP_COLON;
+ msg += msg::usrmsg::USER_PROTOCOL_VERSION;
+ msg += SEP_COMMA;
msg += msg::usrmsg::FLD_TYPE;
msg += SEP_COLON;
- msg += msg::usrmsg::MSGTYPE_HANDSHAKE_CHALLENGE;
+ msg += msg::usrmsg::MSGTYPE_USER_CHALLENGE;
msg += SEP_COMMA;
msg += msg::usrmsg::FLD_CONTRACT_ID;
msg += SEP_COLON;
msg += conf::cfg.contractid;
msg += SEP_COMMA;
+ msg += msg::usrmsg::FLD_CONTRACT_VERSION;
+ msg += SEP_COLON;
+ msg += conf::cfg.contractversion;
+ msg += SEP_COMMA;
msg += msg::usrmsg::FLD_CHALLENGE;
msg += SEP_COLON;
- msg += challengehex;
+ msg += challenge;
+ msg += "\"}";
+ }
+
+ /**
+ * Constructs server challenge response message json. This gets sent when we receive
+ * a challenge from the user.
+ *
+ * @param msg String reference to copy the generated json message string into.
+ * Message format:
+ * {
+ * "type": "server_challenge_response",
+ * "sig": "",
+ * "pubkey": ""
+ * }
+ * @param original_challenge Original challenge issued by the user.
+ */
+ void create_server_challenge_response(std::vector &msg, const std::string &original_challenge)
+ {
+ // Generate signature of challenge + contract id + contract version.
+ const std::string content = original_challenge + conf::cfg.contractid + conf::cfg.contractversion;
+ const std::string sig_hex = crypto::sign_hex(content, conf::cfg.seckeyhex);
+
+ // Since we know the rough size of the challenge message we reserve adequate amount for the holder.
+ msg.reserve(256);
+ msg += "{\"";
+ msg += msg::usrmsg::FLD_TYPE;
+ msg += SEP_COLON;
+ msg += msg::usrmsg::MSGTYPE_SERVER_CHALLENGE_RESPONSE;
+ msg += SEP_COMMA;
+ msg += msg::usrmsg::FLD_SIG;
+ msg += SEP_COLON;
+ msg += sig_hex;
+ msg += SEP_COMMA;
+ msg += msg::usrmsg::FLD_PUBKEY;
+ msg += SEP_COLON;
+ msg += conf::cfg.pubkeyhex;
msg += "\"}";
}
@@ -146,16 +189,33 @@ namespace msg::usrmsg::json
*/
void create_contract_read_response_container(std::vector &msg, std::string_view content)
{
- msg.reserve(256);
+ msg.reserve(content.size() + 256);
msg += "{\"";
msg += msg::usrmsg::FLD_TYPE;
msg += SEP_COLON;
msg += msg::usrmsg::MSGTYPE_CONTRACT_READ_RESPONSE;
msg += SEP_COMMA;
msg += msg::usrmsg::FLD_CONTENT;
- msg += SEP_COLON;
- msg += content;
- msg += "\"}";
+ msg += SEP_COLON_NOQUOTE;
+
+ if (is_json_string(content))
+ {
+ // Process the final string using jsoncons.
+ jsoncons::json jstring = content;
+ jsoncons::json_options options;
+ options.escape_all_non_ascii(true);
+
+ std::string escaped_content;
+ jstring.dump(escaped_content);
+
+ msg += escaped_content;
+ }
+ else
+ {
+ msg += content;
+ }
+
+ msg += "}";
}
/**
@@ -172,7 +232,9 @@ namespace msg::usrmsg::json
*/
void create_contract_output_container(std::vector &msg, std::string_view content, const uint64_t lcl_seq_no, std::string_view lcl)
{
- msg.reserve(256);
+ const bool is_string = is_json_string(content);
+
+ msg.reserve(content.size() + 256);
msg += "{\"";
msg += msg::usrmsg::FLD_TYPE;
msg += SEP_COLON;
@@ -187,9 +249,26 @@ namespace msg::usrmsg::json
msg += std::to_string(lcl_seq_no);
msg += SEP_COMMA_NOQUOTE;
msg += msg::usrmsg::FLD_CONTENT;
- msg += SEP_COLON;
- msg += content;
- msg += "\"}";
+ msg += SEP_COLON_NOQUOTE;
+
+ if (is_json_string(content))
+ {
+ // Process the final string using jsoncons.
+ jsoncons::json jstring = content;
+ jsoncons::json_options options;
+ options.escape_all_non_ascii(true);
+
+ std::string escaped_content;
+ jstring.dump(escaped_content);
+
+ msg += escaped_content;
+ }
+ else
+ {
+ msg += content;
+ }
+
+ msg += "}";
}
/**
@@ -198,80 +277,107 @@ namespace msg::usrmsg::json
*
* @param extracted_pubkeyhex The hex public key extracted from the response.
* @param extracted_protocol The protocol code extracted from the response.
+ * @param extracted_server_challenge Any server challenge issued by user.
* @param response The response bytes to verify. This will be parsed as json.
* Accepted response format:
* {
- * "type": "handshake_response",
- * "challenge": "",
+ * "type": "user_challenge_response",
* "sig": "",
* "pubkey": "",
+ * "server_challenge": "", (max 16 bytes/32 chars)
* "protocol": ""
* }
- * @param original_challenge The original hex challenge string issued to the user.
+ * @param original_challenge The original challenge string we issued to the user.
* @return 0 if challenge response is verified. -1 if challenge not met or an error occurs.
*/
- int verify_user_handshake_response(std::string &extracted_pubkeyhex, std::string &extracted_protocol,
- std::string_view response, std::string_view original_challenge)
+ int verify_user_challenge(std::string &extracted_pubkeyhex, std::string &extracted_protocol, std::string &extracted_server_challenge,
+ std::string_view response, std::string_view original_challenge)
{
jsoncons::json d;
if (parse_user_message(d, response) != 0)
return -1;
// Validate msg type.
- if (d[msg::usrmsg::FLD_TYPE] != msg::usrmsg::MSGTYPE_HANDSHAKE_RESPONSE)
+ if (d[msg::usrmsg::FLD_TYPE] != msg::usrmsg::MSGTYPE_USER_CHALLENGE_RESPONSE)
{
- LOG_DEBUG << "User handshake response type invalid. 'handshake_response' expected.";
- return -1;
- }
-
- // Compare the response handshake string with the original issued challenge.
- if (!d.contains(msg::usrmsg::FLD_CHALLENGE) || d[msg::usrmsg::FLD_CHALLENGE] != original_challenge.data())
- {
- LOG_DEBUG << "User handshake response 'challenge' invalid.";
+ LOG_DEBUG << "User challenge response type invalid. 'handshake_response' expected.";
return -1;
}
// Check for the 'sig' field existence.
if (!d.contains(msg::usrmsg::FLD_SIG) || !d[msg::usrmsg::FLD_SIG].is())
{
- LOG_DEBUG << "User handshake response 'challenge signature' invalid.";
+ LOG_DEBUG << "User challenge response 'challenge signature' invalid.";
return -1;
}
// Check for the 'pubkey' field existence.
if (!d.contains(msg::usrmsg::FLD_PUBKEY) || !d[msg::usrmsg::FLD_PUBKEY].is())
{
- LOG_DEBUG << "User handshake response 'public key' invalid.";
+ LOG_DEBUG << "User challenge response 'public key' invalid.";
return -1;
}
+ // Check for optional server challenge field existence and valid value.
+ if (d.contains(msg::usrmsg::FLD_SERVER_CHALLENGE))
+ {
+ bool server_challenge_valid = false;
+
+ if (d[msg::usrmsg::FLD_SERVER_CHALLENGE].is())
+ {
+ std::string_view challenge = d[msg::usrmsg::FLD_SERVER_CHALLENGE].as();
+
+ if (!challenge.empty() && challenge.size() <= 32)
+ {
+ server_challenge_valid = true;
+ extracted_server_challenge = challenge;
+ }
+ }
+
+ if (!server_challenge_valid)
+ {
+ LOG_DEBUG << "User challenge response 'server_challenge' invalid.";
+ return -1;
+ }
+ }
+
// Check for protocol field existence and valid value.
if (!d.contains(msg::usrmsg::FLD_PROTOCOL) || !d[msg::usrmsg::FLD_PROTOCOL].is())
{
-
- LOG_DEBUG << "User handshake response 'protocol' invalid.";
+ LOG_DEBUG << "User challenge response 'protocol' invalid.";
return -1;
}
std::string_view protocolsv = d[msg::usrmsg::FLD_PROTOCOL].as();
if (protocolsv != "json" && protocolsv != "bson")
{
- LOG_DEBUG << "User handshake response 'protocol' type invalid.";
+ LOG_DEBUG << "User challenge response 'protocol' type invalid.";
return -1;
}
// Verify the challenge signature. We do this last due to signature verification cost.
- std::string_view pubkeysv = d[msg::usrmsg::FLD_PUBKEY].as();
- if (crypto::verify_hex(
- original_challenge,
- d[msg::usrmsg::FLD_SIG].as(),
- pubkeysv) != 0)
+
+ std::string_view pubkey_hex = d[msg::usrmsg::FLD_PUBKEY].as();
+ std::string pubkey_bytes;
+ pubkey_bytes.resize(crypto::PFXD_PUBKEY_BYTES);
+ util::hex2bin(reinterpret_cast(pubkey_bytes.data()),
+ pubkey_bytes.size(),
+ pubkey_hex);
+
+ std::string_view sig_hex = d[msg::usrmsg::FLD_SIG].as();
+ std::string sig_bytes;
+ sig_bytes.resize(sig_hex.size() / 2);
+ util::hex2bin(reinterpret_cast(sig_bytes.data()),
+ sig_bytes.size(),
+ sig_hex);
+
+ if (crypto::verify(original_challenge, sig_bytes, pubkey_bytes) != 0)
{
LOG_DEBUG << "User challenge response signature verification failed.";
return -1;
}
- extracted_pubkeyhex = pubkeysv;
+ extracted_pubkeyhex = pubkey_hex;
extracted_protocol = protocolsv;
return 0;
@@ -436,4 +542,31 @@ namespace msg::usrmsg::json
return 0;
}
+ bool is_json_string(std::string_view content)
+ {
+ if (content.empty())
+ return true;
+
+ const char first = content[0];
+ const char last = content[content.size() - 1];
+
+ if ((first == '\"' && last == '\"') ||
+ (first == '{' && last == '}') ||
+ (first == '[' && last == ']') ||
+ content == "true" || content == "false")
+ return false;
+
+ // Check whether all characters are digits.
+ bool decimal_found = false;
+ for (const char c : content)
+ {
+ if ((c != '.' && (c < '0' || c > '9')) || (c == '.' && decimal_found)) // Not a number.
+ return true;
+ else if (c == '.') // There can only be one decimal in a proper number.
+ decimal_found = true;
+ }
+
+ return false; // Is a number.
+ }
+
} // namespace msg::usrmsg::json
\ No newline at end of file
diff --git a/src/msg/json/usrmsg_json.hpp b/src/msg/json/usrmsg_json.hpp
index a0c11323..03357057 100644
--- a/src/msg/json/usrmsg_json.hpp
+++ b/src/msg/json/usrmsg_json.hpp
@@ -6,7 +6,9 @@
namespace msg::usrmsg::json
{
- void create_user_challenge(std::vector &msg, std::string &challengehex);
+ void create_user_challenge(std::vector &msg, std::string &challenge);
+
+ void create_server_challenge_response(std::vector &msg, const std::string &original_challenge);
void create_status_response(std::vector &msg, const uint64_t lcl_seq_no, std::string_view lcl);
@@ -17,8 +19,8 @@ namespace msg::usrmsg::json
void create_contract_output_container(std::vector &msg, std::string_view content, const uint64_t lcl_seq_no, std::string_view lcl);
- int verify_user_handshake_response(std::string &extracted_pubkeyhex, std::string &extracted_protocol,
- std::string_view response, std::string_view original_challenge);
+ int verify_user_challenge(std::string &extracted_pubkeyhex, std::string &extracted_protocol, std::string &extracted_server_challenge,
+ std::string_view response, std::string_view original_challenge);
int parse_user_message(jsoncons::json &d, std::string_view message);
@@ -32,6 +34,8 @@ namespace msg::usrmsg::json
int extract_input_container(std::string &input, std::string &nonce,
uint64_t &max_lcl_seqno, std::string_view contentjson);
+ bool is_json_string(std::string_view content);
+
} // namespace msg::usrmsg::json
#endif
\ No newline at end of file
diff --git a/src/msg/usrmsg_common.hpp b/src/msg/usrmsg_common.hpp
index cf36765d..177aa07d 100644
--- a/src/msg/usrmsg_common.hpp
+++ b/src/msg/usrmsg_common.hpp
@@ -7,10 +7,14 @@ namespace msg::usrmsg
{
// Length of user random challenge bytes.
constexpr size_t CHALLENGE_LEN = 16;
+ constexpr const char *USER_PROTOCOL_VERSION = "0.0";
// Message field names
+ constexpr const char *FLD_HP_VERSION = "hp_version";
constexpr const char *FLD_TYPE = "type";
+ constexpr const char *FLD_SERVER_CHALLENGE = "server_challenge";
constexpr const char *FLD_CONTRACT_ID = "contract_id";
+ constexpr const char *FLD_CONTRACT_VERSION = "contract_version";
constexpr const char *FLD_CHALLENGE = "challenge";
constexpr const char *FLD_SIG = "sig";
constexpr const char *FLD_PUBKEY = "pubkey";
@@ -27,8 +31,9 @@ namespace msg::usrmsg
constexpr const char *FLD_REASON = "reason";
// Message types
- constexpr const char *MSGTYPE_HANDSHAKE_CHALLENGE = "handshake_challenge";
- constexpr const char *MSGTYPE_HANDSHAKE_RESPONSE = "handshake_response";
+ constexpr const char *MSGTYPE_USER_CHALLENGE = "user_challenge";
+ constexpr const char *MSGTYPE_USER_CHALLENGE_RESPONSE = "user_challenge_response";
+ constexpr const char *MSGTYPE_SERVER_CHALLENGE_RESPONSE = "server_challenge_response";
constexpr const char *MSGTYPE_CONTRACT_READ_REQUEST = "contract_read_request";
constexpr const char *MSGTYPE_CONTRACT_READ_RESPONSE = "contract_read_response";
constexpr const char *MSGTYPE_CONTRACT_INPUT = "contract_input";
diff --git a/src/usr/usr.cpp b/src/usr/usr.cpp
index 969b9c43..de4bcfb6 100644
--- a/src/usr/usr.cpp
+++ b/src/usr/usr.cpp
@@ -92,10 +92,17 @@ namespace usr
std::string user_pubkey_hex;
std::string protocol_code;
- std::string_view original_challenge = session.issued_challenge;
-
- if (msg::usrmsg::json::verify_user_handshake_response(user_pubkey_hex, protocol_code, message, original_challenge) == 0)
+ std::string server_challenge;
+ if (msg::usrmsg::json::verify_user_challenge(user_pubkey_hex, protocol_code, server_challenge, message, session.issued_challenge) == 0)
{
+ // If user has specified server challange, we need to send a challenge response.
+ if (!server_challenge.empty())
+ {
+ std::vector msg;
+ msg::usrmsg::json::create_server_challenge_response(msg, server_challenge);
+ session.send(msg);
+ }
+
// Challenge signature verification successful. Add the user to our global user list.
add_user(session, user_pubkey_hex, protocol_code);
return 0;
diff --git a/src/util/util.hpp b/src/util/util.hpp
index 9c26e1cd..72af3dad 100644
--- a/src/util/util.hpp
+++ b/src/util/util.hpp
@@ -15,8 +15,8 @@ namespace util
// Hot Pocket version. Displayed on 'hotpocket version' and written to new contract configs.
constexpr const char *HP_VERSION = "0.1";
- // Minimum compatible contract config version (this will be used to validate contract configs)
- constexpr const char *MIN_CONTRACT_VERSION = "0.1";
+ // Minimum compatible config version (this will be used to validate contract configs)
+ constexpr const char *MIN_CONFIG_VERSION = "0.1";
// Current version of the peer message protocol.
constexpr uint8_t PEERMSG_VERSION = 1;
diff --git a/test/local-cluster/cluster-create.sh b/test/local-cluster/cluster-create.sh
index 1e424e1b..f2e23610 100755
--- a/test/local-cluster/cluster-create.sh
+++ b/test/local-cluster/cluster-create.sh
@@ -84,7 +84,7 @@ do
# Update contract config.
node -p "JSON.stringify({...require('./tmp.json'), \
- contractid: 'dummy', \
+ contractid: '3c349abe-4d70-4f50-9fa6-018f1f2530ab', \
binary: '$binary', \
binargs: '$binargs', \
appbill: '', \
diff --git a/test/vm-cluster/cluster.sh b/test/vm-cluster/cluster.sh
index 7177eeb1..1fe79496 100755
--- a/test/vm-cluster/cluster.sh
+++ b/test/vm-cluster/cluster.sh
@@ -364,7 +364,7 @@ do
# Merge json contents to produce final contract config.
echo "$(cat ./cfg/node$n.cfg)" \
- '{"contractid":"dummy"}' \
+ '{"contractid":"3c349abe-4d70-4f50-9fa6-018f1f2530ab"}' \
'{"binary":"/usr/bin/node"}' \
'{"binargs":"'$basedir'/hpfiles/nodejs_contract/echo_contract.js"}' \
'{"peers":'${mypeers}'}' \