500 lines
13 KiB
JavaScript
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);
|
|
}
|
|
}
|