datona-lib
Version:
Core library for access to the Datona IO Platform. Datona Protocol v0.0.2. Adds HTTP and WebSocket support.
403 lines (336 loc) • 11.9 kB
JavaScript
;
/*
* Datona Comms Library
*
* datona-lib utility functions for communicating between Datona applications.
* Implements the Datona application level protocol, including signatures and
* encryption.
*
* Copyright (C) 2020 Datona Labs
*
* This program is free software; you can redistribute it and/or
* modify it under the terms of the GNU Lesser General Public
* License as published by the Free Software Foundation; either
* version 3 of the License, or (at your option) any later version.
*
* This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU
* Lesser General Public License for more details.
*
* You should have received a copy of the GNU Lesser General Public License
* along with this program; if not, write to the Free Software Foundation,
* Inc., 51 Franklin Street, Fifth Floor, Boston, MA 02110-1301, USA.
*
*/
const errors = require('./errors');
const assert = require('./assertions');
const crypto = require('./datona-crypto');
var WebSocket;
var net;
var axios;
/*
* Classes
*/
/*
* Interface for clients that communicate with a remote vault or requester server.
*/
class DatonaClient {
constructor(url) {
assert.isUrl(url, "url");
this.url = url;
}
/*
* Promises to send the given JSON formatted SignedTransaction and resolve
* with the response or reject with a CommunicationError.
*/
send(signedTxnStr) {
throw new errors.DeveloperError("DatonaClient send has not been implemented")
};
}
/*
* DatonaClient implementation for a plain TCP (file://) connection
*/
class TcpClient extends DatonaClient {
constructor(url, connectionTimeout = 3000) {
super(url);
this.connectionTimeout = connectionTimeout;
if (net === undefined) net = require('net');
}
send(signedTxnStr) {
return new Promise((resolve, reject) => {
const socket = new net.Socket();
socket.setTimeout(this.connectionTimeout, () => {
socket.destroy();
reject(new errors.CommunicationError("Connection timeout"));
});
socket.connect(this.url.port, this.url.host, () => {
socket.write(signedTxnStr);
});
socket.on('data', (dataBuffer) => {
socket.destroy();
resolve(dataBuffer.toString());
});
socket.on('error', (err) => {
socket.destroy();
reject(new errors.CommunicationError("Failed to send transaction: " + err));
});
});
}
}
/*
* DatonaClient implementation for a websocket connection
*/
class WebSocketClient extends DatonaClient {
constructor(url, connectionTimeout = 3000) {
super(url);
this.connectionTimeout = connectionTimeout;
if (WebSocket === undefined) WebSocket = require('isomorphic-ws');
}
send(signedTxnStr) {
return new Promise((resolve, reject) => {
const socket = new WebSocket(this.url.scheme + "://" + this.url.host + ":" + this.url.port);
const timer = setTimeout(() => {
socket.close();
reject(new errors.CommunicationError("Connection timeout"));
}, this.connectionTimeout);
socket.onopen = function (evt) {
clearTimeout(timer);
socket.send(signedTxnStr);
};
socket.onmessage = function (evt) {
//socket.close();
resolve(evt.data.toString());
};
socket.onerror = function (evt) {
socket.close();
reject(new errors.CommunicationError("Failed to send transaction: " + evt.message, evt.error));
};
});
}
}
/*
* DatonaClient implementation for an http connection
*/
class HttpClient extends DatonaClient {
constructor(url, connectionTimeout = 3000) {
super(url);
if (axios === undefined) axios = require('axios');
this.socket = axios.create({ baseURL: this.url.scheme + "://" + this.url.host + ":" + this.url.port + "/" });
this.connectionTimeout = connectionTimeout;
}
send(signedTxnStr) {
let source = axios.CancelToken.source();
const timer = setTimeout(() => { source.cancel("request timed out"); }, this.connectionTimeout*10);
return this.socket.post("", signedTxnStr, {cancelToken: source.token, headers: {'Content-Type': 'application/json'}})
.then( (response) => {
clearTimeout(timer);
return JSON.stringify(response.data);
})
.catch( (error) => {
throw new errors.CommunicationError("Failed to send transaction: " + error.message, error);
});
}
}
/*
* Superclass for classes that communicate with a remote vault or requester server.
* The DatonaConnector automatically selects the appropriate DatonaClient based on
* the url scheme. Allows datona-lib to seamlessly support multiple types of
* transmission protocol.
*/
class DatonaConnector {
constructor(url, localPrivateKey, remoteAddress) {
assert.isUrl(url, "url");
assert.isString(url.scheme, "url.scheme");
assert.isInstanceOf(localPrivateKey, "localPrivateKey", crypto.Key);
assert.isAddress(remoteAddress, "remoteAddress");
this.localPrivateKey = localPrivateKey;
this.remoteAddress = remoteAddress;
switch (url.scheme) {
case "file":
this.client = new TcpClient(url);
break;
case "ws":
this.client = new WebSocketClient(url);
break;
case "http":
case "https":
this.client = new HttpClient(url);
break;
default:
throw new errors.RequestError("Unsupported url scheme: "+url.scheme);
}
}
/*
* Serialises the given Transaction object, signs it and returns a promise
* to send it to the requester.
*/
send(txn){
const signedTxnStr = encodeTransaction(txn, this.localPrivateKey);
const decode = this._decode;
return this.client.send(signedTxnStr)
.then(decode.bind(this));
}
/*
* Decodes the given transaction object and returns the data payload.
* Throws a TransactionError if the transaction is invalid or the
* signature does not match the expected remotePublicKey.
*/
_decode(txnStr) {
const txn = decodeTransaction(txnStr);
if (txn.signatory.toLowerCase() !== this.remoteAddress.toLowerCase()){
throw new errors.TransactionError("Validation failure. Wrong signatory", "Expected: "+this.remoteAddress+", Received: "+txn.signatory);
}
return txn;
}
}
/*
* A Smart Data Access request from a Requester to an Owner. This class
* validates the request and allows the user to accept or reject the request.
* If accept or reject is called, this class connects to the Requester's
* remote server to send the response.
*/
class SmartDataAccessRequest extends DatonaConnector {
constructor(signedTxnStr, localPrivateKey) {
assert.isString(signedTxnStr, "SmartDataAccessRequest constructor signedTxnStr");
assert.isInstanceOf(localPrivateKey, "SmartDataAccessRequest constructor key", crypto.Key);
const txn = decodeTransaction(signedTxnStr);
try {
assert.isObject(txn.txn.api, "api");
assert.isObject(txn.txn.contract, "contract");
assert.isHash(txn.txn.contract.hash, "contract hash");
super(txn.txn.api.url, localPrivateKey, txn.signatory);
this.data = txn.txn;
if (txn.txn.txnType !== "SmartDataAccessRequest") throw new errors.RequestError("Invalid transaction type ('"+txn.txn.txnType+"')");
} catch (error) {
throw new errors.RequestError("Request is invalid: " + error.message, error.details);
}
}
/*
* Sends an acceptance transaction to the requester giving the address of
* the SDAC and the vault URL.
*/
accept(contractAddress, vaultAddress, vaultUrl) {
assert.isAddress(contractAddress, "SmartDataAccessRequest accept contractAddress");
assert.isAddress(vaultAddress, "SmartDataAccessRequest accept vaultAddress");
assert.isUrl(vaultUrl, "SmartDataAccessRequest accept vaultUrl");
var txn = this.data.api.acceptTransaction;
txn.txnType = "SmartDataAccessResponse";
txn.responseType = "accept";
txn.contract = contractAddress;
txn.vaultAddress = vaultAddress;
txn.vaultUrl = vaultUrl;
return this.send(txn)
.then( function(signedTxn){
validateResponse(signedTxn.txn);
return signedTxn;
});
}
/*
* Sends a rejection transaction to the requester with the reason given.
*/
reject(reason) {
assert.isPresent(reason, "SmartDataAccessRequest reject reason");
var txn = this.data.api.rejectTransaction;
txn.txnType = "SmartDataAccessResponse";
txn.responseType = "reject";
txn.reason = reason;
return this.send(txn);
}
}
/*
* Exports
*/
module.exports = {
DatonaConnector: DatonaConnector,
SmartDataAccessRequest: SmartDataAccessRequest,
encodeTransaction: encodeTransaction,
decodeTransaction: decodeTransaction,
validateResponse: validateResponse,
createSuccessResponse: createSuccessResponse,
createErrorResponse: createErrorResponse
};
/*
* External Functions
*/
/*
* Decodes the given transaction object and returns the data payload.
* @throws a TransactionError if the transaction data or signature is invalid.
*/
function decodeTransaction(signedTxnStr) {
assert.isString(signedTxnStr, "txnStr");
try {
const signedTxn = JSON.parse(signedTxnStr);
assert.isPresent(signedTxn.txn, "txn");
assert.isPresent(signedTxn.signature, "signature");
const txnHash = crypto.hash(JSON.stringify(signedTxn.txn));
const signatory = crypto.recover(txnHash, signedTxn.signature);
return { txn:signedTxn.txn, signatory:signatory };
}
catch (error) {
throw new errors.MalformedTransactionError(error.message, error.details);
}
}
/*
* Serialises the given object and signs it.
*/
function encodeTransaction(txn, key) {
assert.isObject(txn, "Comms.encodeTransaction txn");
assert.isInstanceOf(key, "Comms.encodeTransaction key", crypto.Key );
const txnStr = JSON.stringify(txn);
const signature = key.sign(crypto.hash(txnStr));
const signedTxn = {
txn: txn,
signature: signature
};
return JSON.stringify(signedTxn);
}
/*
* Validates the given response transaction against the GeneralServerResponse
* format.
*/
function validateResponse(txn, expectedTxnType = "GeneralResponse") {
try {
assert.isString(expectedTxnType, "GeneralResponse constructor expectedTxnType");
assert.isObject(txn, expectedTxnType+" constructor txn");
assert.isString(txn.txnType, "txnType");
if (txn.txnType !== expectedTxnType) throw new errors.TransactionError("invalid transaction type ('"+txn.txnType+"')");
assert.isString(txn.responseType, "responseType");
switch (txn.responseType) {
case "success": break;
case "error":
assert.isObject(txn.error, "error");
assert.isPresent(txn.error.message, "error message");
// name and details are optional
break;
default:
throw new errors.TransactionError("invalid response type ("+txn.responseType+")");
}
} catch (error) {
throw new errors.TransactionError(expectedTxnType+" is invalid: " + error.message, error.details);
}
}
/*
* Constructs a GeneralServerResponse Success transaction, optionally of the given type.
*/
function createSuccessResponse(txnType = "GeneralResponse") {
return {
txnType: txnType,
responseType: "success"
};
}
/*
* Constructs a GeneralServerResponse Error transaction, optionally of the given type.
*/
function createErrorResponse(error, txnType = "GeneralResponse") {
assert.isInstanceOf(error, "createErrorResponse error", Error);
return {
txnType: txnType,
responseType: "error",
error: {
name: error.name,
message: error.message,
details: error.details
}
}
}