zecwallet-lite/app/companion.js

500 lines
13 KiB
JavaScript

/* eslint-disable flowtype/no-weak-types */
/* eslint-disable max-classes-per-file */
/* eslint-disable class-methods-use-this */
import _sodium from 'libsodium-wrappers-sumo';
import Store from 'electron-store';
import WebSocket from 'ws';
import AppState, { ConnectedCompanionApp } from './components/AppState';
import Utils from './utils/utils';
// Wormhole code is sha256(sha256(secret_key))
function getWormholeCode(keyHex: string, sodium: any): string {
const key = sodium.from_hex(keyHex);
const pass1 = sodium.crypto_hash_sha256(key);
const pass2 = sodium.to_hex(sodium.crypto_hash_sha256(pass1));
return pass2;
}
// A class that connects to wormhole given a secret key
class WormholeClient {
keyHex: string;
wormholeCode: string;
sodium: any;
wss: WebSocket = null;
listner: CompanionAppListener = null;
keepAliveTimerID: TimerID = null;
constructor(keyHex: string, sodium: any, listner: CompanionAppListener) {
this.keyHex = keyHex;
this.sodium = sodium;
this.listner = listner;
this.wormholeCode = getWormholeCode(keyHex, this.sodium);
this.connect();
}
connect() {
this.wss = new WebSocket('wss://wormhole.zecqtwallet.com:443');
this.wss.on('open', () => {
// On open, register ourself
const reg = { register: getWormholeCode(this.keyHex, this.sodium) };
// No encryption for the register call
this.wss.send(JSON.stringify(reg));
// Now, do a ping every 4 minutes to keep the connection alive.
this.keepAliveTimerID = setInterval(() => {
const ping = { ping: 'ping' };
this.wss.send(JSON.stringify(ping));
}, 4 * 60 * 1000);
});
this.wss.on('message', data => {
this.listner.processIncoming(data, this.keyHex, this.wss);
});
this.wss.on('close', (code, reason) => {
console.log('Socket closed for ', this.keyHex, code, reason);
});
this.wss.on('error', (ws, err) => {
console.log('ws error', err);
});
}
getKeyHex(): string {
return this.keyHex;
}
close() {
if (this.keepAliveTimerID) {
clearInterval(this.keepAliveTimerID);
}
// Close the websocket.
if (this.wss) {
this.wss.close();
}
}
}
// The singleton Companion App listener, that can spawn a wormhole server
// or (multiple) wormhole clients
export default class CompanionAppListener {
sodium = null;
fnGetState: () => AppState = null;
fnSendTransaction: ([]) => string = null;
fnUpdateConnectedClient: (string, number) => void = null;
permWormholeClient: WormholeClient = null;
tmpWormholeClient: WormholeClient = null;
constructor(
fnGetSate: () => AppState,
fnSendTransaction: ([]) => string,
fnUpdateConnectedClient: (string, number) => void
) {
this.fnGetState = fnGetSate;
this.fnSendTransaction = fnSendTransaction;
this.fnUpdateConnectedClient = fnUpdateConnectedClient;
}
async setUp() {
await _sodium.ready;
this.sodium = _sodium;
// Create a new wormhole listner
const permKeyHex = this.getEncKey();
if (permKeyHex) {
this.permWormholeClient = new WormholeClient(permKeyHex, this.sodium, this);
}
// At startup, set the last client name/time by loading it
const store = new Store();
const name = store.get('companion/name');
const lastSeen = store.get('companion/lastseen');
if (name && lastSeen) {
const o = new ConnectedCompanionApp();
o.name = name;
o.lastSeen = lastSeen;
this.fnUpdateConnectedClient(o);
}
}
createTmpClient(keyHex: string) {
if (this.tmpWormholeClient) {
this.tmpWormholeClient.close();
}
this.tmpWormholeClient = new WormholeClient(keyHex, this.sodium, this);
}
closeTmpClient() {
if (this.tmpWormholeClient) {
this.tmpWormholeClient.close();
this.tmpWormholeClient = null;
}
}
replacePermClientWithTmp() {
if (this.permWormholeClient) {
this.permWormholeClient.close();
}
// Replace the stored code with the new one
this.permWormholeClient = this.tmpWormholeClient;
this.tmpWormholeClient = null;
this.setEncKey(this.permWormholeClient.getKeyHex());
// Reset local nonce
const store = new Store();
store.delete('companion/localnonce');
}
processIncoming(data: string, keyHex: string, ws: Websocket) {
const dataJson = JSON.parse(data);
// If the wormhole sends some messages, we ignore them
if ('error' in dataJson) {
console.log('Incoming data contains an error message', data);
return;
}
// If the message is a ping, just ignore it
if ('ping' in dataJson) {
return;
}
// Then, check if the message is encrpted
if (!('nonce' in dataJson)) {
const err = { error: 'Encryption error', to: getWormholeCode(keyHex, this.sodium) };
ws.send(JSON.stringify(err));
return;
}
let cmd;
// If decryption passes and this is a tmp wormhole client, then set it as the permanant client
if (this.tmpWormholeClient && keyHex === this.tmpWormholeClient.getKeyHex()) {
const { decrypted, nonce } = this.decryptIncoming(data, keyHex, false);
if (!decrypted) {
console.log('Decryption failed');
const err = { error: 'Encryption error', to: getWormholeCode(keyHex, this.sodium) };
ws.send(JSON.stringify(err));
return;
}
cmd = JSON.parse(decrypted);
// Replace the permanant client
this.replacePermClientWithTmp();
this.updateRemoteNonce(nonce);
} else {
const { decrypted, nonce } = this.decryptIncoming(data, keyHex, true);
if (!decrypted) {
const err = { error: 'Encryption error', to: getWormholeCode(keyHex, this.sodium) };
ws.send(JSON.stringify(err));
console.log('Decryption failed');
return;
}
cmd = JSON.parse(decrypted);
this.updateRemoteNonce(nonce);
}
if (cmd.command === 'getInfo') {
const response = this.doGetInfo(cmd);
ws.send(this.encryptOutgoing(response, keyHex));
} else if (cmd.command === 'getTransactions') {
const response = this.doGetTransactions();
ws.send(this.encryptOutgoing(response, keyHex));
} else if (cmd.command === 'sendTx') {
const response = this.doSendTransaction(cmd, ws);
ws.send(this.encryptOutgoing(response, keyHex));
}
}
// Generate a new secret key
genNewKeyHex(): string {
const keyHex = this.sodium.to_hex(this.sodium.crypto_secretbox_keygen());
return keyHex;
}
getEncKey(): string {
// Get the nonce. Increment and store the nonce for next use
const store = new Store();
const keyHex = store.get('companion/key');
return keyHex;
}
setEncKey(keyHex: string) {
// Get the nonce. Increment and store the nonce for next use
const store = new Store();
store.set('companion/key', keyHex);
}
saveLastClientName(name: string) {
// Save the last client name
const store = new Store();
store.set('companion/name', name);
if (name) {
const now = Date.now();
store.set('companion/lastseen', now);
const o = new ConnectedCompanionApp();
o.name = name;
o.lastSeen = now;
this.fnUpdateConnectedClient(o);
} else {
this.fnUpdateConnectedClient(null);
}
}
disconnectLastClient() {
// Remove the permanant connection
if (this.permWormholeClient) {
this.permWormholeClient.close();
}
this.saveLastClientName(null);
this.setEncKey(null);
}
getRemoteNonce(): string {
const store = new Store();
const nonceHex = store.get('companion/remotenonce');
return nonceHex;
}
updateRemoteNonce(nonce: string) {
if (nonce) {
const store = new Store();
store.set('companion/remotenonce', nonce);
}
}
getLocalNonce(): string {
// Get the nonce. Increment and store the nonce for next use
const store = new Store();
const nonceHex = store.get('companion/localnonce', `01${'00'.repeat(this.sodium.crypto_secretbox_NONCEBYTES - 1)}`);
// Increment nonce
const newNonce = this.sodium.from_hex(nonceHex);
this.sodium.increment(newNonce);
this.sodium.increment(newNonce);
store.set('companion/localnonce', this.sodium.to_hex(newNonce));
return nonceHex;
}
encryptOutgoing(str: string, keyHex: string): string {
if (!keyHex) {
console.log('No secret key');
throw Error('No Secret Key');
}
const nonceHex = this.getLocalNonce();
const nonce = this.sodium.from_hex(nonceHex);
const key = this.sodium.from_hex(keyHex);
const encrypted = this.sodium.crypto_secretbox_easy(str, nonce, key);
const encryptedHex = this.sodium.to_hex(encrypted);
const resp = {
nonce: this.sodium.to_hex(nonce),
payload: encryptedHex,
to: getWormholeCode(keyHex, this.sodium)
};
return JSON.stringify(resp);
}
decryptIncoming(msg: string, keyHex: string, checkNonce: boolean): any {
const msgJson = JSON.parse(msg);
console.log('trying to decrypt', msgJson);
if (!keyHex) {
console.log('No secret key');
throw Error('No Secret Key');
}
const key = this.sodium.from_hex(keyHex);
const nonce = this.sodium.from_hex(msgJson.nonce);
if (checkNonce) {
const prevNonce = this.sodium.from_hex(this.getRemoteNonce());
if (prevNonce && this.sodium.compare(prevNonce, nonce) >= 0) {
return { decrypted: null };
}
}
const cipherText = this.sodium.from_hex(msgJson.payload);
const decrypted = this.sodium.to_string(this.sodium.crypto_secretbox_open_easy(cipherText, nonce, key));
return { decrypted, nonce: msgJson.nonce };
}
doGetInfo(cmd: any): string {
const appState = this.fnGetState();
if (cmd && cmd.name) {
this.saveLastClientName(cmd.name);
}
const saplingAddress = appState.addresses.find(a => Utils.isSapling(a));
const tAddress = appState.addresses.find(a => Utils.isTransparent(a));
const balance = parseFloat(appState.totalBalance.total);
const maxspendable = parseFloat(appState.totalBalance.total);
const maxzspendable = parseFloat(appState.totalBalance.private);
const tokenName = appState.info.currencyName;
const zecprice = parseFloat(appState.info.zecPrice);
const resp = {
version: 1.0,
command: 'getInfo',
saplingAddress,
tAddress,
balance,
maxspendable,
maxzspendable,
tokenName,
zecprice,
serverversion: '0.9.2'
};
return JSON.stringify(resp);
}
doGetTransactions(): string {
const appState = this.fnGetState();
let txlist = [];
if (appState.transactions) {
// Get only the last 20 txns
txlist = appState.transactions.slice(0, 20).map(t => {
let memo = t.detailedTxns && t.detailedTxns.length > 0 ? t.detailedTxns[0].memo : '';
if (memo) {
memo = memo.trimRight();
} else {
memo = '';
}
const txResp = {
type: t.type,
datetime: t.time,
amount: t.amount.toFixed(8),
txid: t.txid,
address: t.address,
memo,
confirmations: t.confirmations
};
return txResp;
});
}
const resp = {
version: 1.0,
command: 'getTransactions',
transactions: txlist
};
return JSON.stringify(resp);
}
doSendTransaction(cmd: any, ws: WebSocket): string {
// "command":"sendTx","tx":{"amount":"0.00019927","to":"zs1pzr7ee53jwa3h3yvzdjf7meruujq84w5rsr5kuvye9qg552kdyz5cs5ywy5hxkxcfvy9wln94p6","memo":""}}
const inpTx = cmd.tx;
const appState = this.fnGetState();
// eslint-disable-next-line radix
const sendingAmount = parseInt((parseFloat(inpTx.amount) * 10 ** 8).toFixed(0));
const buildError = (reason: string): string => {
const resp = {
errorCode: -1,
errorMessage: `Couldn't send Tx:${reason}`
};
// console.log('sendtx error', resp);
return JSON.stringify(resp);
};
// First, find an address that can send the correct amount.
const fromAddress = appState.addressesWithBalance.find(ab => ab.balance > sendingAmount);
if (!fromAddress) {
return buildError(`No address with sufficient balance to send ${sendingAmount}`);
}
const memo = !inpTx.memo || inpTx.memo.trim() === '' ? null : inpTx.memo;
// Build a sendJSON object
const sendJSON = [];
if (memo) {
sendJSON.push({ address: inpTx.to, amount: sendingAmount, memo });
} else {
sendJSON.push({ address: inpTx.to, amount: sendingAmount });
}
console.log('sendjson is', sendJSON);
let resp;
try {
const txid = this.fnSendTransaction(sendJSON);
// After the transaction is submitted, we return an intermediate success.
resp = {
version: 1.0,
command: 'sendTx',
result: 'success'
};
ws.send(this.encryptOutgoing(JSON.stringify(resp)));
// And then another one when the Tx was submitted successfully. For lightclient, this is the same,
// so we end up sending 2 responses back to back
resp = {
version: 1.0,
command: 'sendTxSubmitted',
txid
};
} catch (err) {
resp = {
version: 1.0,
command: 'sendTxFailed',
err
};
}
return JSON.stringify(resp);
}
}