diff --git a/web/cashtab/package-lock.json b/web/cashtab/package-lock.json --- a/web/cashtab/package-lock.json +++ b/web/cashtab/package-lock.json @@ -16,6 +16,7 @@ "dotenv": "^8.2.0", "dotenv-expand": "^5.1.0", "ecashaddrjs": "^1.0.1", + "eccrypto-js": "^5.4.0", "ethereum-blockies-base64": "^1.0.2", "localforage": "^1.9.0", "lodash.isempty": "^4.4.0", @@ -3525,6 +3526,11 @@ "node": ">=8.9" } }, + "node_modules/aes-js": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz", + "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==" + }, "node_modules/aggregate-error": { "version": "3.1.0", "license": "MIT", @@ -8135,6 +8141,20 @@ "safer-buffer": "^2.1.0" } }, + "node_modules/eccrypto-js": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/eccrypto-js/-/eccrypto-js-5.4.0.tgz", + "integrity": "sha512-W4xBr0UANgpkSIu2FBHG5wjhvOR4L19HwAUm9xiA4c3bzB9gGbH8+E9hSMTNF+QbmEi+TDvt373JHKA6NnW38A==", + "dependencies": { + "aes-js": "3.1.2", + "enc-utils": "2.1.0", + "hash.js": "1.1.7", + "js-sha3": "0.8.0", + "pbkdf2": "^3.0.17", + "randombytes": "2.1.0", + "secp256k1": "3.8.0" + } + }, "node_modules/ecurve": { "version": "1.0.6", "license": "MIT", @@ -8201,6 +8221,21 @@ "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" }, + "node_modules/enc-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/enc-utils/-/enc-utils-2.1.0.tgz", + "integrity": "sha512-VD0eunGDyzhojePzkORWDnW88gi6tIeGb5Z6QVHugux6mMAPiXyw94fb/7WdDQEWhKMSoYRyzFFUebCqeH20PA==", + "dependencies": { + "bn.js": "4.11.8", + "is-typedarray": "1.0.0", + "typedarray-to-buffer": "3.1.5" + } + }, + "node_modules/enc-utils/node_modules/bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==" + }, "node_modules/encodeurl": { "version": "1.0.2", "dev": true, @@ -14520,6 +14555,11 @@ "version": "0.9.0", "license": "MIT" }, + "node_modules/js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==" + }, "node_modules/js-tokens": { "version": "4.0.0", "license": "MIT" @@ -30950,6 +30990,11 @@ "regex-parser": "^2.2.11" } }, + "aes-js": { + "version": "3.1.2", + "resolved": "https://registry.npmjs.org/aes-js/-/aes-js-3.1.2.tgz", + "integrity": "sha512-e5pEa2kBnBOgR4Y/p20pskXI74UEz7de8ZGVo58asOtvSVG5YAbJeELPZxOmt+Bnz3rX753YKhfIn4X4l1PPRQ==" + }, "aggregate-error": { "version": "3.1.0", "requires": { @@ -34210,6 +34255,20 @@ "safer-buffer": "^2.1.0" } }, + "eccrypto-js": { + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/eccrypto-js/-/eccrypto-js-5.4.0.tgz", + "integrity": "sha512-W4xBr0UANgpkSIu2FBHG5wjhvOR4L19HwAUm9xiA4c3bzB9gGbH8+E9hSMTNF+QbmEi+TDvt373JHKA6NnW38A==", + "requires": { + "aes-js": "3.1.2", + "enc-utils": "2.1.0", + "hash.js": "1.1.7", + "js-sha3": "0.8.0", + "pbkdf2": "^3.0.17", + "randombytes": "2.1.0", + "secp256k1": "3.8.0" + } + }, "ecurve": { "version": "1.0.6", "requires": { @@ -34256,6 +34315,23 @@ "resolved": "https://registry.npmjs.org/enabled/-/enabled-2.0.0.tgz", "integrity": "sha512-AKrN98kuwOzMIdAizXGI86UFBoo26CL21UM763y1h/GMSJ4/OHU9k2YlsmBpyScFo/wbLzWQJBMCW4+IO3/+OQ==" }, + "enc-utils": { + "version": "2.1.0", + "resolved": "https://registry.npmjs.org/enc-utils/-/enc-utils-2.1.0.tgz", + "integrity": "sha512-VD0eunGDyzhojePzkORWDnW88gi6tIeGb5Z6QVHugux6mMAPiXyw94fb/7WdDQEWhKMSoYRyzFFUebCqeH20PA==", + "requires": { + "bn.js": "4.11.8", + "is-typedarray": "1.0.0", + "typedarray-to-buffer": "3.1.5" + }, + "dependencies": { + "bn.js": { + "version": "4.11.8", + "resolved": "https://registry.npmjs.org/bn.js/-/bn.js-4.11.8.tgz", + "integrity": "sha512-ItfYfPLkWHUjckQCk8xC+LwxgK8NYcXywGigJgSwOP8Y2iyWT4f2vsZnoOXTTbo+o5yXmIUJ4gn5538SO5S3gA==" + } + } + }, "encodeurl": { "version": "1.0.2", "dev": true @@ -38476,6 +38552,11 @@ "js-sha256": { "version": "0.9.0" }, + "js-sha3": { + "version": "0.8.0", + "resolved": "https://registry.npmjs.org/js-sha3/-/js-sha3-0.8.0.tgz", + "integrity": "sha512-gF1cRrHhIzNfToc802P800N8PpXS+evLLXfsVpowqmAFR9uwbi89WvXg2QspOmXL8QL86J4T1EpFu+yUkwJY3Q==" + }, "js-tokens": { "version": "4.0.0" }, diff --git a/web/cashtab/package.json b/web/cashtab/package.json --- a/web/cashtab/package.json +++ b/web/cashtab/package.json @@ -12,6 +12,7 @@ "dotenv": "^8.2.0", "dotenv-expand": "^5.1.0", "ecashaddrjs": "^1.0.1", + "eccrypto-js": "^5.4.0", "ethereum-blockies-base64": "^1.0.2", "localforage": "^1.9.0", "lodash.isempty": "^4.4.0", diff --git a/web/cashtab/src/components/Common/Ticker.js b/web/cashtab/src/components/Common/Ticker.js --- a/web/cashtab/src/components/Common/Ticker.js +++ b/web/cashtab/src/components/Common/Ticker.js @@ -34,10 +34,12 @@ opReturnPrefixHex: '6a', opReturnPushDataHex: '04', opReturnAppPrefixLengthHex: '04', + opPushDataOne: '4c', appPrefixesHex: { eToken: '534c5000', cashtab: '00746162', }, + encryptionPrefix: '656e6372797074', // 'encrypt' }, settingsValidation: { fiatCurrency: [ @@ -114,6 +116,13 @@ return hexStr.startsWith(getCashtabEncodingSubstring()); } +export function isMessageEncrypted(hexStr) { + if (!hexStr || typeof hexStr !== 'string') { + return false; + } + return hexStr.startsWith(currency.opReturn.encryptionPrefix); +} + export function isEtokenOutput(hexStr) { if (!hexStr || typeof hexStr !== 'string') { return false; @@ -121,21 +130,64 @@ return hexStr.startsWith(getETokenEncodingSubstring()); } +export function isLongMessage(hexStr) { + if (!hexStr || typeof hexStr !== 'string') { + return false; + } + return hexStr.startsWith(currency.opReturn.opPushDataOne); +} + +export function removeIncomingBytePrefix(hexSubstring) { + if (!hexSubstring || typeof hexSubstring !== 'string') { + return ''; + } + if (isLongMessage(hexSubstring)) { + // this is a long message with 4 prefix characters preceding the message + hexSubstring = hexSubstring.slice(4); + } else { + // this is a short message with 2 prefix characters preceding the message + hexSubstring = hexSubstring.slice(2); + } + return hexSubstring; +} + export function extractCashtabMessage(hexSubstring) { if (!hexSubstring || typeof hexSubstring !== 'string') { return ''; } - let substring = hexSubstring.replace(getCashtabEncodingSubstring(), ''); // remove the cashtab encoding - substring = substring.slice(2); // remove the 2 bytes indicating the size of the next element on the stack e.g. a0 -> 160 bytes - return substring; + // remove the cashtab encoding + hexSubstring = hexSubstring.replace(getCashtabEncodingSubstring(), ''); + + // remove the incoming byte prefix preceding the message + hexSubstring = removeIncomingBytePrefix(hexSubstring); + + return hexSubstring; } export function extractExternalMessage(hexSubstring) { if (!hexSubstring || typeof hexSubstring !== 'string') { return ''; } - let substring = hexSubstring.slice(4); // remove the preceding OP_RETURN prefixes - return substring; + // remove the OP_RETURN encoding + hexSubstring = hexSubstring.replace(currency.opReturn.encryptionPrefix, ''); + + // remove the incoming byte prefix preceding the message + hexSubstring = removeIncomingBytePrefix(hexSubstring); + + return hexSubstring; +} + +export function extractEncryptedMessage(hexSubstring) { + if (!hexSubstring || typeof hexSubstring !== 'string') { + return ''; + } + + hexSubstring = hexSubstring.replace(currency.opReturn.encryptionPrefix, ''); + + // remove the incoming byte prefix preceding the message + hexSubstring = removeIncomingBytePrefix(hexSubstring); + + return hexSubstring; } export function isValidCashPrefix(addressString) { diff --git a/web/cashtab/src/components/Send/Send.js b/web/cashtab/src/components/Send/Send.js --- a/web/cashtab/src/components/Send/Send.js +++ b/web/cashtab/src/components/Send/Send.js @@ -245,6 +245,9 @@ bchValue = fiatToCrypto(value, fiatPrice); } + let encryptionFlag; + encryptionFlag = true; // for testing only + try { const link = await sendBch( BCH, @@ -254,6 +257,7 @@ bchValue, currency.defaultFee, optionalOpReturnMsg, + encryptionFlag, ); sendXecNotification(link); } catch (e) { diff --git a/web/cashtab/src/components/Wallet/Tx.js b/web/cashtab/src/components/Wallet/Tx.js --- a/web/cashtab/src/components/Wallet/Tx.js +++ b/web/cashtab/src/components/Wallet/Tx.js @@ -41,6 +41,15 @@ word-break: break-word; padding-left: 13px; padding-right: 30px; + /* invisible scrollbar */ + overflow: hidden; + height: 100%; + margin-right: -50px; /* Maximum width of scrollbar */ + padding-right: 50px; /* Maximum width of scrollbar */ + overflow-y: scroll; + ::-webkit-scrollbar { + display: none; + } `; const SentLabel = styled.span` font-weight: bold; @@ -56,6 +65,12 @@ color: ${props => props.theme.primary} !important; white-space: nowrap; `; +const EncryptionMessageLabel = styled.span` + text-align: left; + font-weight: bold; + color: red; + white-space: nowrap; +`; const MessageLabel = styled.span` text-align: left; font-weight: bold; @@ -403,6 +418,13 @@ External Message )} + {data.isEncryptedMessage ? ( + +  - Encrypted + + ) : ( + '' + )}
{data.opReturnMessage ? Buffer.from( diff --git a/web/cashtab/src/hooks/useBCH.js b/web/cashtab/src/hooks/useBCH.js --- a/web/cashtab/src/hooks/useBCH.js +++ b/web/cashtab/src/hooks/useBCH.js @@ -5,6 +5,8 @@ isEtokenOutput, extractCashtabMessage, extractExternalMessage, + isMessageEncrypted, + extractEncryptedMessage, } from '@components/Common/Ticker'; import { isValidTokenStats } from '@utils/validation'; import SlpWallet from 'minimal-slp-wallet'; @@ -18,6 +20,7 @@ confirmNonEtokenUtxos, } from '@utils/cashMethods'; import cashaddr from 'ecashaddrjs'; +const eccrypto = require('eccrypto-js'); export default function useBCH() { const SEND_BCH_ERRORS = { @@ -78,7 +81,7 @@ return flatTxHistory.splice(0, txCount); }; - const parseTxData = txData => { + const parseTxData = async (txData, wallet) => { /* Desired output [ @@ -97,7 +100,6 @@ } ] */ - const parsedTxHistory = []; for (let i = 0; i < txData.length; i += 1) { const tx = txData[i]; @@ -124,6 +126,7 @@ let amountReceived = 0; let opReturnMessage = ''; let isCashtabMessage = false; + let isEncryptedMessage = false; // Assume an incoming transaction let outgoingTx = false; let tokenTx = false; @@ -152,26 +155,72 @@ !Object.keys(thisOutput.scriptPubKey).includes('addresses') ) { let hex = thisOutput.scriptPubKey.hex; - + console.log('Full hex is: ' + hex); if (isEtokenOutput(hex)) { // this is an eToken transaction tokenTx = true; } else if (isCashtabOutput(hex)) { // this is a cashtab.com generated message - try { - substring = extractCashtabMessage(hex); - opReturnMessage = Buffer.from(substring, 'hex'); - isCashtabMessage = true; - } catch (err) { - // soft error if an unexpected or invalid cashtab hex is encountered - opReturnMessage = ''; + console.log('Cashtab message found'); + substring = extractCashtabMessage(hex); + + if (isMessageEncrypted(substring)) { + // this is an encrypted Cashtab message + console.log('encrypted message found'); + + substring = extractEncryptedMessage(substring); console.log( - 'useBCH.parsedTxHistory() error: invalid cashtab msg hex: ' + - substring, + 'extracted encryption string is: ' + substring, ); + + let fundingWif; + if ( + wallet && + wallet.state && + wallet.state.slpBalancesAndUtxos + ) { + fundingWif = + wallet.state.slpBalancesAndUtxos + .nonSlpUtxos[0].wif; + } else { + break; + } + + console.log('WIF for decryption: ' + fundingWif); + + // Convert the hex encoded message to a buffer + const msgBuf = Buffer.from(substring, 'hex'); + let structData; + let decryptedMessage; + try { + // Convert the bufer into a structured object. + structData = convertToEncryptStruct(msgBuf); + decryptedMessage = await eccrypto.decrypt( + fundingWif, + structData, + ); + console.log( + 'decryption successful, message: ' + + decryptedMessage, + ); + } catch (err) { + console.log('parseTx decryption error: ' + err); + decryptedMessage = 'Encrypted Message'; + } + + console.log( + 'parseTx.Decrypted message:' + decryptedMessage, + ); + opReturnMessage = decryptedMessage; + isEncryptedMessage = true; + } else { + // this is an un-encrypted Cashtab message + opReturnMessage = substring; } + isCashtabMessage = true; } else { // this is an externally generated message + console.log('Un-encrypted message found'); try { substring = extractExternalMessage(hex); opReturnMessage = Buffer.from(substring, 'hex'); @@ -210,11 +259,50 @@ parsedTx.destinationAddress = destinationAddress; parsedTx.opReturnMessage = opReturnMessage; parsedTx.isCashtabMessage = isCashtabMessage; + parsedTx.isEncryptedMessage = isEncryptedMessage; parsedTxHistory.push(parsedTx); } return parsedTxHistory; }; + // Converts a serialized buffer containing encrypted data into an object + // that can interpreted by the eccryptoJS library. + const convertToEncryptStruct = encbuf => { + try { + let offset = 0; + const tagLength = 32; + let pub; + switch (encbuf[0]) { + case 4: + pub = encbuf.slice(0, 65); + break; + case 3: + case 2: + pub = encbuf.slice(0, 33); + break; + default: + throw new Error(`Invalid type: ${encbuf[0]}`); + } + offset += pub.length; + + const c = encbuf.slice(offset, encbuf.length - tagLength); + const ivbuf = c.slice(0, 128 / 8); + const ctbuf = c.slice(128 / 8); + + const d = encbuf.slice(encbuf.length - tagLength, encbuf.length); + + return { + iv: ivbuf, + ephemPublicKey: pub, + ciphertext: ctbuf, + mac: d, + }; + } catch (err) { + console.error(`Error in convertToEncryptStruct()`); + throw err; + } + }; + const getTxHistory = async (BCH, addresses) => { let txHistoryResponse; try { @@ -257,7 +345,7 @@ return txDataWithPassThrough; }; - const getTxData = async (BCH, txHistory) => { + const getTxData = async (BCH, txHistory, wallet) => { // Flatten tx history let flatTxs = flattenTransactions(txHistory); @@ -276,7 +364,7 @@ try { txDataPromiseResponse = await Promise.all(txDataPromises); - const parsed = parseTxData(txDataPromiseResponse); + const parsed = parseTxData(txDataPromiseResponse, wallet); return parsed; } catch (err) { @@ -967,6 +1055,7 @@ sendAmount, feeInSatsPerByte, optionalOpReturnMsg, + encryptionFlag, ) => { try { if (!sendAmount) { @@ -1005,20 +1094,62 @@ throw error; } + let script; // Start of building the OP_RETURN output. // only build the OP_RETURN output if the user supplied it if ( typeof optionalOpReturnMsg !== 'undefined' && optionalOpReturnMsg.trim() !== '' ) { - const script = [ - BCH.Script.opcodes.OP_RETURN, // 6a - Buffer.from( - currency.opReturn.appPrefixesHex.cashtab, - 'hex', - ), // 00746162 - Buffer.from(optionalOpReturnMsg), - ]; + if (encryptionFlag) { + //if the user has opted to encrypt this message + let recipientPubKey = await getPublicKey( + BCH, + destinationAddress, + ); + console.log('PUBLIC KEY IS: ' + recipientPubKey); + + const pubKeyBuf = Buffer.from(recipientPubKey, 'hex'); + const bufferedFile = Buffer.from(optionalOpReturnMsg); + const structuredEj = await eccrypto.encrypt( + pubKeyBuf, + bufferedFile, + ); + + // Serialize the encrypted data object + const encryptedEj = Buffer.concat([ + structuredEj.ephemPublicKey, + structuredEj.iv, + structuredEj.ciphertext, + structuredEj.mac, + ]); + + const encryptedMessage = encryptedEj.toString('hex'); + + console.log('Original message: ' + optionalOpReturnMsg); + console.log('Encrypted message: ' + encryptedMessage); + + // build the OP_RETURN script with the encryption prefix + script = [ + BCH.Script.opcodes.OP_RETURN, // 6a + Buffer.from( + currency.opReturn.appPrefixesHex.cashtab, + 'hex', + ), // 00746162 + Buffer.from(currency.opReturn.encryptionPrefix, 'hex'), // 656e6372797074 a.k.a 'encrypt' + Buffer.from(encryptedEj), + ]; + } else { + // this is an un-encrypted message + script = [ + BCH.Script.opcodes.OP_RETURN, // 6a + Buffer.from( + currency.opReturn.appPrefixesHex.cashtab, + 'hex', + ), // 00746162 + Buffer.from(optionalOpReturnMsg), + ]; + } const data = BCH.Script.encode(script); transactionBuilder.addOutput(data, 0); } @@ -1131,6 +1262,16 @@ } }; + const getPublicKey = async (BCH, address) => { + try { + const publicKey = await BCH.encryption.getPubKey(address); + return publicKey.publicKey; + } catch (err) { + console.log(`useBCH.getPublicKey() error: `, err); + throw err; + } + }; + const getBCH = (apiIndex = 0) => { let ConstructedSlpWallet; diff --git a/web/cashtab/src/hooks/useWallet.js b/web/cashtab/src/hooks/useWallet.js --- a/web/cashtab/src/hooks/useWallet.js +++ b/web/cashtab/src/hooks/useWallet.js @@ -240,7 +240,7 @@ hydratedUtxoDetails, ); const txHistory = await getTxHistory(BCH, cashAddresses); - const parsedTxHistory = await getTxData(BCH, txHistory); + const parsedTxHistory = await getTxData(BCH, txHistory, wallet); const parsedWithTokens = await addTokenTxData(BCH, parsedTxHistory);