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}'}' \