Page MenuHomePhabricator

D10692.id.diff
No OneTemporary

D10692.id.diff

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",
+ "ecies-lite": "^1.0.7",
"ethereum-blockies-base64": "^1.0.2",
"localforage": "^1.9.0",
"lodash.isempty": "^4.4.0",
@@ -33,7 +34,8 @@
"react-image": "^4.0.3",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
- "styled-components": "^4.4.0"
+ "styled-components": "^4.4.0",
+ "wif": "^2.0.6"
},
"devDependencies": {
"@ant-design/dark-theme": "^1.0.3",
@@ -8135,6 +8137,11 @@
"safer-buffer": "^2.1.0"
}
},
+ "node_modules/ecies-lite": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/ecies-lite/-/ecies-lite-1.0.7.tgz",
+ "integrity": "sha512-FcT30ao9Crn8LoKw4x/ekp85KddxsNrYp4jxoxfX6RdBG9rEAL/pNK5sJ4j9x1Z22ooA8cILj+iD9bVdMc9opw=="
+ },
"node_modules/ecurve": {
"version": "1.0.6",
"license": "MIT",
@@ -28060,7 +28067,8 @@
},
"node_modules/wif": {
"version": "2.0.6",
- "license": "MIT",
+ "resolved": "https://registry.npmjs.org/wif/-/wif-2.0.6.tgz",
+ "integrity": "sha1-CNP1IFbGZnkplyb63g1DKudLRwQ=",
"dependencies": {
"bs58check": "<3.0.0"
}
@@ -34210,6 +34218,11 @@
"safer-buffer": "^2.1.0"
}
},
+ "ecies-lite": {
+ "version": "1.0.7",
+ "resolved": "https://registry.npmjs.org/ecies-lite/-/ecies-lite-1.0.7.tgz",
+ "integrity": "sha512-FcT30ao9Crn8LoKw4x/ekp85KddxsNrYp4jxoxfX6RdBG9rEAL/pNK5sJ4j9x1Z22ooA8cILj+iD9bVdMc9opw=="
+ },
"ecurve": {
"version": "1.0.6",
"requires": {
@@ -47634,6 +47647,8 @@
},
"wif": {
"version": "2.0.6",
+ "resolved": "https://registry.npmjs.org/wif/-/wif-2.0.6.tgz",
+ "integrity": "sha1-CNP1IFbGZnkplyb63g1DKudLRwQ=",
"requires": {
"bs58check": "<3.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",
+ "ecies-lite": "^1.0.7",
"ethereum-blockies-base64": "^1.0.2",
"localforage": "^1.9.0",
"lodash.isempty": "^4.4.0",
@@ -29,7 +30,8 @@
"react-image": "^4.0.3",
"react-router": "^5.2.0",
"react-router-dom": "^5.2.0",
- "styled-components": "^4.4.0"
+ "styled-components": "^4.4.0",
+ "wif": "^2.0.6"
},
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx,json,css,scss,md}": [
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
@@ -35,7 +35,10 @@
appPrefixesHex: {
eToken: '534c5000',
cashtab: '00746162',
+ cashtabEncrypted: '65746162',
},
+ encryptedMsgCharLimit: 94,
+ unencryptedMsgCharLimit: 160,
},
settingsValidation: {
fiatCurrency: [
@@ -141,6 +144,12 @@
) {
// add the extracted Cashtab prefix to array
resultArray[i] = currency.opReturn.appPrefixesHex.cashtab;
+ } else if (
+ i === 0 &&
+ message === currency.opReturn.appPrefixesHex.cashtabEncrypted
+ ) {
+ // add the Cashtab encryption prefix to array
+ resultArray[i] = currency.opReturn.appPrefixesHex.cashtabEncrypted;
} else {
// this is either an external message or a subsequent cashtab message loop to extract the message
resultArray[i] = message;
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
@@ -12,7 +12,7 @@
import { Form, message, Modal, Alert, Collapse, Input, Button } from 'antd';
const { Panel } = Collapse;
const { TextArea } = Input;
-import { Row, Col } from 'antd';
+import { Row, Col, Switch } from 'antd';
import PrimaryButton, {
SecondaryButton,
SmartButton,
@@ -90,6 +90,11 @@
const [msgToSign, setMsgToSign] = useState('');
const [signMessageIsValid, setSignMessageIsValid] = useState(null);
const [isOneToManyXECSend, setIsOneToManyXECSend] = useState(false);
+ const [opReturnMsg, setOpReturnMsg] = useState(false);
+ const [isEncryptedOptionalOpReturnMsg, setIsEncryptedOptionalOpReturnMsg] =
+ useState(false);
+ const [bchObj, setBchObj] = useState(false);
+
// Get device window width
// If this is less than 769, the page will open with QR scanner open
const { width } = useWindowDimensions();
@@ -99,7 +104,6 @@
const [formData, setFormData] = useState({
value: '',
address: '',
- opReturnMsg: '',
});
const [queryStringText, setQueryStringText] = useState(null);
const [sendBchAddressError, setSendBchAddressError] = useState(false);
@@ -119,8 +123,8 @@
setFormData({
value: '',
address: '',
- opReturnMsg: '',
});
+ setOpReturnMsg(''); // OP_RETURN message has its own state field
};
const showModal = () => {
@@ -138,9 +142,6 @@
const { getBCH, getRestUrl, sendXec, calcFee, signPkMessage } = useBCH();
- // jestBCH is only ever specified for unit tests, otherwise app will use getBCH();
- const BCH = jestBCH ? jestBCH : getBCH();
-
// If the balance has changed, unlock the UI
// This is redundant, if backend has refreshed in 1.75s timeout below, UI will already be unlocked
useEffect(() => {
@@ -148,6 +149,12 @@
}, [balances.totalBalance]);
useEffect(() => {
+ // jestBCH is only ever specified for unit tests, otherwise app will use getBCH();
+ const BCH = jestBCH ? jestBCH : getBCH();
+
+ // set the BCH instance to state, for other functions to reference
+ setBchObj(BCH);
+
// Manually parse for txInfo object on page load when Send.js is loaded with a query string
// if this was routed from Wallet screen's Reply to message link then prepopulate the address and value field
@@ -228,8 +235,6 @@
...formData,
});
- let optionalOpReturnMsg = formData.opReturnMsg;
-
if (isOneToManyXECSend) {
// this is a one to many XEC send transactions
@@ -254,11 +259,11 @@
toLegacyCashArray(addressAndValueArray);
const link = await sendXec(
- BCH,
+ bchObj,
wallet,
slpBalancesAndUtxos.nonSlpUtxos,
currency.defaultFee,
- optionalOpReturnMsg,
+ opReturnMsg,
true, // indicate send mode is one to many
cleanAddressAndValueArray,
);
@@ -299,9 +304,20 @@
bchValue = fiatToCrypto(value, fiatPrice);
}
+ // encrypted message limit truncation
+ let optionalOpReturnMsg;
+ if (isEncryptedOptionalOpReturnMsg) {
+ optionalOpReturnMsg = opReturnMsg.substring(
+ 0,
+ currency.opReturn.encryptedMsgCharLimit,
+ );
+ } else {
+ optionalOpReturnMsg = opReturnMsg;
+ }
+
try {
const link = await sendXec(
- BCH,
+ bchObj,
wallet,
slpBalancesAndUtxos.nonSlpUtxos,
currency.defaultFee,
@@ -310,6 +326,7 @@
null, // address array not applicable for one to many tx
cleanAddress,
bchValue,
+ isEncryptedOptionalOpReturnMsg,
);
sendXecNotification(link);
clearInputForms();
@@ -454,15 +471,6 @@
}));
};
- const handleOpReturnMsgChange = e => {
- const { value, name } = e.target;
-
- setFormData(p => ({
- ...p,
- [name]: value,
- }));
- };
-
// true: renders the multi recipient <TextArea>
// false: renders the single recipient <Input>
const handleOneToManyXECSend = sendXecMode => {
@@ -500,7 +508,7 @@
const signMessageByPk = async () => {
try {
const messageSignature = await signPkMessage(
- BCH,
+ bchObj,
wallet.Path1899.fundingWif,
msgToSign,
);
@@ -531,7 +539,7 @@
// Set currency to BCH
setSelectedCurrency(currency.ticker);
try {
- const txFeeSats = calcFee(BCH, slpBalancesAndUtxos.nonSlpUtxos);
+ const txFeeSats = calcFee(bchObj, slpBalancesAndUtxos.nonSlpUtxos);
const txFeeBch = txFeeSats / 10 ** currency.cashDecimals;
let value =
@@ -771,22 +779,78 @@
marginBottom: '20px',
}}
>
- <TextAreaLabel>Message:</TextAreaLabel>
- <Alert
- style={{ marginBottom: '10px' }}
- description="Messages are public."
- type="warning"
- showIcon
- />
+ <TextAreaLabel>
+ Message:&nbsp;&nbsp;
+ {!isOneToManyXECSend ? (
+ <Switch
+ checkedChildren="Private"
+ unCheckedChildren="Public"
+ defaultunchecked="true"
+ checked={
+ isEncryptedOptionalOpReturnMsg
+ }
+ onChange={() =>
+ setIsEncryptedOptionalOpReturnMsg(
+ prev => !prev,
+ )
+ }
+ style={{
+ marginBottom: '7px',
+ }}
+ />
+ ) : (
+ ''
+ )}
+ </TextAreaLabel>
+ {isEncryptedOptionalOpReturnMsg ? (
+ <Alert
+ style={{
+ marginBottom: '10px',
+ }}
+ description="Please note encrypted messages can only be sent to wallets with at least 1 outgoing transaction."
+ type="warning"
+ showIcon
+ />
+ ) : (
+ <Alert
+ style={{
+ marginBottom: '10px',
+ }}
+ description="Please note this message will be public."
+ type="warning"
+ showIcon
+ />
+ )}
<TextArea
name="opReturnMsg"
- placeholder="(max 160 characters)"
- value={formData.opReturnMsg}
+ placeholder={
+ isEncryptedOptionalOpReturnMsg
+ ? `(max ${currency.opReturn.encryptedMsgCharLimit} characters)`
+ : `(max ${currency.opReturn.unencryptedMsgCharLimit} characters)`
+ }
+ value={
+ opReturnMsg
+ ? isEncryptedOptionalOpReturnMsg
+ ? opReturnMsg.substring(
+ 0,
+ currency.opReturn
+ .encryptedMsgCharLimit +
+ 1,
+ )
+ : opReturnMsg
+ : ''
+ }
onChange={e =>
- handleOpReturnMsgChange(e)
+ setOpReturnMsg(e.target.value)
}
showCount
- maxLength={160}
+ maxLength={
+ isEncryptedOptionalOpReturnMsg
+ ? currency.opReturn
+ .encryptedMsgCharLimit
+ : currency.opReturn
+ .unencryptedMsgCharLimit
+ }
onKeyDown={e =>
e.keyCode == 13
? e.preventDefault()
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,18 @@
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 UnauthorizedDecryptionMessage = styled.span`
+ text-align: left;
+ color: red;
+ white-space: nowrap;
+ font-style: italic;
+`;
const MessageLabel = styled.span`
text-align: left;
font-weight: bold;
@@ -403,12 +424,41 @@
External Message
</MessageLabel>
)}
+ {data.isEncryptedMessage ? (
+ <EncryptionMessageLabel>
+ &nbsp;-&nbsp;Encrypted
+ </EncryptionMessageLabel>
+ ) : (
+ ''
+ )}
<br />
- {data.opReturnMessage
+ {/*unencrypted OP_RETURN Message*/}
+ {data.opReturnMessage &&
+ !data.isEncryptedMessage
? Buffer.from(
data.opReturnMessage,
).toString()
: ''}
+ {/*encrypted and wallet is authorized to view OP_RETURN Message*/}
+ {data.opReturnMessage &&
+ data.isEncryptedMessage &&
+ data.decryptionSuccess
+ ? Buffer.from(
+ data.opReturnMessage,
+ ).toString()
+ : ''}
+ {/*encrypted but wallet is not authorized to view OP_RETURN Message*/}
+ {data.opReturnMessage &&
+ data.isEncryptedMessage &&
+ !data.decryptionSuccess ? (
+ <UnauthorizedDecryptionMessage>
+ {Buffer.from(
+ data.opReturnMessage,
+ ).toString()}
+ </UnauthorizedDecryptionMessage>
+ ) : (
+ ''
+ )}
{!data.outgoingTx && data.replyAddress ? (
<Link
to={{
diff --git a/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap b/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap
--- a/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap
+++ b/web/cashtab/src/components/Wallet/__tests__/__snapshots__/Wallet.test.js.snap
@@ -3,7 +3,7 @@
exports[`Wallet with BCH balances 1`] = `
Array [
<div
- className="sc-fMiknA fHWQKC"
+ className="sc-eqIVtm gpHdyi"
>
<span
aria-label="party emoji"
@@ -29,7 +29,7 @@
to send to others
</div>,
<div
- className="sc-jhAzac jLDVSr"
+ className="sc-fMiknA fUPKHz"
>
0
@@ -119,16 +119,16 @@
</div>
</div>,
<div
- className="sc-TOsTZ ihWfGS"
+ className="sc-cJSrbW grtjkC"
>
<div
- className="sc-kgAjT kbUjME"
+ className="sc-ksYbfQ hhXWxr"
onClick={[Function]}
>
XEC
</div>
<div
- className="sc-kgAjT kbUjME nonactiveBtn"
+ className="sc-ksYbfQ hhXWxr nonactiveBtn"
onClick={[Function]}
>
eToken
@@ -140,7 +140,7 @@
exports[`Wallet with BCH balances and tokens 1`] = `
Array [
<div
- className="sc-fMiknA fHWQKC"
+ className="sc-eqIVtm gpHdyi"
>
<span
aria-label="party emoji"
@@ -166,7 +166,7 @@
to send to others
</div>,
<div
- className="sc-jhAzac jLDVSr"
+ className="sc-fMiknA fUPKHz"
>
0
@@ -256,16 +256,16 @@
</div>
</div>,
<div
- className="sc-TOsTZ ihWfGS"
+ className="sc-cJSrbW grtjkC"
>
<div
- className="sc-kgAjT kbUjME"
+ className="sc-ksYbfQ hhXWxr"
onClick={[Function]}
>
XEC
</div>
<div
- className="sc-kgAjT kbUjME nonactiveBtn"
+ className="sc-ksYbfQ hhXWxr nonactiveBtn"
onClick={[Function]}
>
eToken
@@ -277,14 +277,14 @@
exports[`Wallet with BCH balances and tokens and state field 1`] = `
Array [
<div
- className="sc-jhAzac jLDVSr"
+ className="sc-fMiknA fUPKHz"
>
0.06
XEC
</div>,
<div
- className="sc-fBuWsC cWYAdq"
+ className="sc-dVhcbM eDJCmc"
>
$
NaN
@@ -375,16 +375,16 @@
</div>
</div>,
<div
- className="sc-TOsTZ ihWfGS"
+ className="sc-cJSrbW grtjkC"
>
<div
- className="sc-kgAjT kbUjME"
+ className="sc-ksYbfQ hhXWxr"
onClick={[Function]}
>
XEC
</div>
<div
- className="sc-kgAjT kbUjME nonactiveBtn"
+ className="sc-ksYbfQ hhXWxr nonactiveBtn"
onClick={[Function]}
>
eToken
@@ -396,7 +396,7 @@
exports[`Wallet without BCH balance 1`] = `
Array [
<div
- className="sc-fMiknA fHWQKC"
+ className="sc-eqIVtm gpHdyi"
>
<span
aria-label="party emoji"
@@ -422,7 +422,7 @@
to send to others
</div>,
<div
- className="sc-jhAzac jLDVSr"
+ className="sc-fMiknA fUPKHz"
>
0
@@ -512,16 +512,16 @@
</div>
</div>,
<div
- className="sc-TOsTZ ihWfGS"
+ className="sc-cJSrbW grtjkC"
>
<div
- className="sc-kgAjT kbUjME"
+ className="sc-ksYbfQ hhXWxr"
onClick={[Function]}
>
XEC
</div>
<div
- className="sc-kgAjT kbUjME nonactiveBtn"
+ className="sc-ksYbfQ hhXWxr nonactiveBtn"
onClick={[Function]}
>
eToken
diff --git a/web/cashtab/src/hooks/__mocks__/mockParsedTxs.js b/web/cashtab/src/hooks/__mocks__/mockParsedTxs.js
--- a/web/cashtab/src/hooks/__mocks__/mockParsedTxs.js
+++ b/web/cashtab/src/hooks/__mocks__/mockParsedTxs.js
@@ -10,9 +10,11 @@
height: 674993,
outgoingTx: true,
isCashtabMessage: false,
+ isEncryptedMessage: false,
opReturnMessage: '',
replyAddress: null,
tokenTx: false,
+ decryptionSuccess: false,
txid: '089f2188d5771a7de0589def2b8d6c1a1f33f45b6de26d9a0ef32782f019ecf1',
},
];
@@ -28,9 +30,11 @@
height: 672077,
outgoingTx: false,
isCashtabMessage: false,
+ isEncryptedMessage: false,
opReturnMessage: '',
replyAddress: null,
tokenTx: false,
+ decryptionSuccess: false,
txid: '42d39fbe068a40fe691f987b22fdf04b80f94d71d2fec20a58125e7b1a06d2a9',
},
];
@@ -46,9 +50,11 @@
height: 674444,
outgoingTx: true,
isCashtabMessage: false,
+ isEncryptedMessage: false,
opReturnMessage: '',
replyAddress: null,
tokenTx: true,
+ decryptionSuccess: false,
txid: 'ffe3a7500dbcc98021ad581c98d9947054d1950a7f3416664715066d3d20ad72',
},
];
@@ -63,9 +69,11 @@
height: 674143,
outgoingTx: false,
isCashtabMessage: false,
+ isEncryptedMessage: false,
opReturnMessage: '',
replyAddress: null,
tokenTx: true,
+ decryptionSuccess: false,
txid: '618d0dd8c0c5fa5a34c6515c865dd72bb76f8311cd6ee9aef153bab20dabc0e6',
},
];
@@ -82,6 +90,8 @@
outgoingTx: false,
tokenTx: false,
isCashtabMessage: false,
+ isEncryptedMessage: false,
+ decryptionSuccess: false,
txid: 'dd35690b0cefd24dcc08acba8694ecd49293f365a81372cb66c8f1c1291d97c5',
},
];
@@ -98,6 +108,8 @@
outgoingTx: false,
tokenTx: false,
isCashtabMessage: true,
+ isEncryptedMessage: false,
+ decryptionSuccess: false,
txid: '5adc33b5c0509b31c6da359177b19467c443bdc4dd37c283c0f87244c0ad63af',
},
];
diff --git a/web/cashtab/src/hooks/__tests__/useBCH.test.js b/web/cashtab/src/hooks/__tests__/useBCH.test.js
--- a/web/cashtab/src/hooks/__tests__/useBCH.test.js
+++ b/web/cashtab/src/hooks/__tests__/useBCH.test.js
@@ -145,6 +145,80 @@
);
});
+ it('sends XEC correctly with an encrypted OP_RETURN message', async () => {
+ const { sendXec } = useBCH();
+ const BCH = new BCHJS();
+ const {
+ expectedTxId,
+ expectedHex,
+ utxos,
+ wallet,
+ destinationAddress,
+ sendAmount,
+ } = sendBCHMock;
+
+ BCH.RawTransactions.sendRawTransaction = jest
+ .fn()
+ .mockResolvedValue(expectedTxId);
+ expect(
+ await sendXec(
+ BCH,
+ wallet,
+ utxos,
+ currency.defaultFee,
+ 'This is an encrypted opreturn message',
+ false,
+ null,
+ destinationAddress,
+ sendAmount,
+ true, // encryption flag for the OP_RETURN message
+ ),
+ ).toBe(`${currency.blockExplorerUrl}/tx/${expectedTxId}`);
+ });
+
+ it('sends XEC throws error attempting to encrypt a message with an invalid address', async () => {
+ const { sendXec } = useBCH();
+ const BCH = new BCHJS();
+ const {
+ expectedTxId,
+ expectedHex,
+ utxos,
+ wallet,
+ destinationAddress,
+ sendAmount,
+ } = sendBCHMock;
+
+ const expectedError = {
+ error: `Unsupported address format : INVALIDADDRESS`,
+ success: false,
+ };
+
+ BCH.RawTransactions.sendRawTransaction = jest
+ .fn()
+ .mockResolvedValue(expectedTxId);
+
+ let thrownError;
+
+ try {
+ await sendXec(
+ BCH,
+ wallet,
+ utxos,
+ currency.defaultFee,
+ 'This is an encrypted opreturn message',
+ false,
+ null,
+ 'INVALIDADDRESS',
+ sendAmount,
+ true, // encryption flag for the OP_RETURN message
+ );
+ } catch (err) {
+ thrownError = err;
+ }
+
+ expect(thrownError).toStrictEqual(expectedError);
+ });
+
it('sends one to many XEC correctly', async () => {
const { sendXec } = useBCH();
const BCH = new BCHJS();
@@ -510,4 +584,146 @@
),
).toStrictEqual(mockReceivedOpReturnMessageTx);
});
+
+ it(`handleEncryptedOpReturn() correctly encrypts a message based on a valid cash address`, async () => {
+ const { handleEncryptedOpReturn } = useBCH();
+ const BCH = new BCHJS();
+ const destinationAddress =
+ 'bitcoincash:qqvuj09f80sw9j7qru84ptxf0hyqffc38gstxfs5ru';
+ const message =
+ 'This message is encrypted by ecies-lite with default parameters';
+
+ const result = await handleEncryptedOpReturn(
+ BCH,
+ destinationAddress,
+ Buffer.from(message),
+ );
+
+ // loop through each ecies encryption parameter from the object returned from the handleEncryptedOpReturn() call
+ for (const k of Object.keys(result)) {
+ switch (result[k].toString()) {
+ case 'epk':
+ // verify the sender's ephemeral public key buffer
+ expect(result[k].toString()).toEqual(
+ 'BPxEy0o7QsRok2GSpuLU27g0EqLIhf6LIxHx7P5UTZF9EFuQbqGzr5cCA51qVnvIJ9CZ84iW1DeDdvhg/EfPSas=',
+ );
+ break;
+ case 'iv':
+ // verify the initialization vector for the cipher algorithm
+ expect(result[k].toString()).toEqual(
+ '2FcU3fRZUOBt7dqshZjd+g==',
+ );
+ break;
+ case 'ct':
+ // verify the encrypted message buffer
+ expect(result[k].toString()).toEqual(
+ 'wVxPjv/ZiQ4etHqqTTIEoKvYYf4po05I/kNySrdsN3verxlHI07Rbob/VfF4MDfYHpYmDwlR9ax1shhdSzUG/A==',
+ );
+ break;
+ case 'mac':
+ // verify integrity of the message (checksum)
+ expect(result[k].toString()).toEqual(
+ 'F9KxuR48O0wxa9tFYq6/Hy3joI2edKxLFSeDVk6JKZE=',
+ );
+ break;
+ }
+ }
+ });
+
+ it(`handleEncryptedOpReturn() correctly throws error when attempting to encrypt a message based on an invalid cash address`, async () => {
+ const { handleEncryptedOpReturn } = useBCH();
+ const BCH = new BCHJS();
+ const destinationAddress = 'bitcoincash:qqvINVALIDADDRESSSSSSru';
+ const message =
+ 'This message is encrypted by ecies-lite with default parameters';
+
+ const expectedError = {
+ error: `Unsupported address format : ${destinationAddress}`,
+ success: false,
+ };
+
+ let thrownError;
+ try {
+ await handleEncryptedOpReturn(
+ BCH,
+ destinationAddress,
+ Buffer.from(message),
+ );
+ } catch (err) {
+ thrownError = err;
+ }
+
+ expect(thrownError).toStrictEqual(expectedError);
+ });
+
+ it(`handleEncryptedOpReturn() correctly throws error when attempting to encrypt a message based on null cash address input`, async () => {
+ const { handleEncryptedOpReturn } = useBCH();
+ const BCH = new BCHJS();
+ const destinationAddress = null;
+ const message =
+ 'This message is encrypted by ecies-lite with default parameters';
+ const expectedError = 'Input must be a valid Bitcoin Cash address.';
+
+ let thrownError;
+ try {
+ await handleEncryptedOpReturn(
+ BCH,
+ destinationAddress,
+ Buffer.from(message),
+ );
+ } catch (err) {
+ thrownError = err;
+ }
+
+ expect(thrownError).toStrictEqual(new Error(expectedError));
+ });
+
+ it(`getRecipientPublicKey() correctly retrieves the public key of a cash address`, async () => {
+ const { getRecipientPublicKey } = useBCH();
+ const BCH = new BCHJS();
+ expect(
+ await getRecipientPublicKey(
+ BCH,
+ 'bitcoincash:qqvuj09f80sw9j7qru84ptxf0hyqffc38gstxfs5ru',
+ ),
+ ).toStrictEqual(
+ '03208c4f52229e021ddec5fc6e07a59fd66388ac52bc2a2c1e0f1afb24b0e275ac',
+ );
+ });
+
+ it(`getRecipientPublicKey() correctly throws error for an invalid cash address`, async () => {
+ const { getRecipientPublicKey } = useBCH();
+ const BCH = new BCHJS();
+ const destinationAddress = 'bitcoincash:qqvuj0INVALIDDDDDDDDDDs5ru';
+
+ const expectedError = {
+ error: `Unsupported address format : ${destinationAddress}`,
+ success: false,
+ };
+
+ let thrownError;
+ try {
+ await getRecipientPublicKey(BCH, destinationAddress);
+ } catch (err) {
+ thrownError = err;
+ }
+
+ expect(thrownError).toStrictEqual(expectedError);
+ });
+
+ it(`getRecipientPublicKey() correctly throws error for a null cash address input`, async () => {
+ const { getRecipientPublicKey } = useBCH();
+ const BCH = new BCHJS();
+ const destinationAddress = null;
+ const expectedError = 'Input must be a valid Bitcoin Cash address.';
+
+ let thrownError;
+ try {
+ await getRecipientPublicKey(BCH, destinationAddress);
+ } catch (err) {
+ thrownError = err;
+ }
+
+ expect(thrownError).toStrictEqual(new Error(expectedError));
+ });
});
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
@@ -10,8 +10,12 @@
isValidStoredWallet,
checkNullUtxosForTokenStatus,
confirmNonEtokenUtxos,
+ convertToEncryptStruct,
+ getPublicKey,
} from '@utils/cashMethods';
import cashaddr from 'ecashaddrjs';
+import ecies from 'ecies-lite';
+import wif from 'wif';
export default function useBCH() {
const SEND_BCH_ERRORS = {
@@ -72,7 +76,7 @@
return flatTxHistory.splice(0, txCount);
};
- const parseTxData = async (BCH, txData, publicKeys) => {
+ const parseTxData = async (BCH, txData, publicKeys, wallet) => {
/*
Desired output
[
@@ -117,6 +121,8 @@
let amountReceived = 0;
let opReturnMessage = '';
let isCashtabMessage = false;
+ let isEncryptedMessage = false;
+ let decryptionSuccess = false;
// Assume an incoming transaction
let outgoingTx = false;
let tokenTx = false;
@@ -198,6 +204,57 @@
parsedOpReturnArray[1],
);
}
+ } else if (
+ txType ===
+ currency.opReturn.appPrefixesHex.cashtabEncrypted
+ ) {
+ // this is an encrypted Cashtab message
+ let msgString = parsedOpReturnArray[1];
+ let fundingWif, privateKeyObj, privateKeyBuff;
+ if (
+ wallet &&
+ wallet.state &&
+ wallet.state.slpBalancesAndUtxos &&
+ wallet.state.slpBalancesAndUtxos.nonSlpUtxos[0]
+ ) {
+ fundingWif =
+ wallet.state.slpBalancesAndUtxos.nonSlpUtxos[0]
+ .wif;
+ privateKeyObj = wif.decode(fundingWif);
+ privateKeyBuff = privateKeyObj.privateKey;
+ if (!privateKeyBuff) {
+ throw new Error('Private key extraction error');
+ }
+ } else {
+ break;
+ }
+
+ let structData;
+ let decryptedMessage;
+
+ try {
+ // Convert the hex encoded message to a buffer
+ const msgBuf = Buffer.from(msgString, 'hex');
+
+ // Convert the bufer into a structured object.
+ structData = convertToEncryptStruct(msgBuf);
+
+ decryptedMessage = await ecies.decrypt(
+ privateKeyBuff,
+ structData,
+ );
+ decryptionSuccess = true;
+ } catch (err) {
+ console.log(
+ 'useBCH.parsedTxData() decryption error: ' +
+ err,
+ );
+ decryptedMessage =
+ 'Only the message recipient can view this';
+ }
+ isCashtabMessage = true;
+ isEncryptedMessage = true;
+ opReturnMessage = decryptedMessage;
} else {
// this is an externally generated message
message = txType; // index 0 is the message content in this instance
@@ -270,6 +327,8 @@
parsedTx.destinationAddress = destinationAddress;
parsedTx.opReturnMessage = opReturnMessage;
parsedTx.isCashtabMessage = isCashtabMessage;
+ parsedTx.isEncryptedMessage = isEncryptedMessage;
+ parsedTx.decryptionSuccess = decryptionSuccess;
parsedTxHistory.push(parsedTx);
}
return parsedTxHistory;
@@ -318,7 +377,7 @@
return txDataWithPassThrough;
};
- const getTxData = async (BCH, txHistory, publicKeys) => {
+ const getTxData = async (BCH, txHistory, publicKeys, wallet) => {
// Flatten tx history
let flatTxs = flattenTransactions(txHistory);
@@ -337,7 +396,12 @@
try {
txDataPromiseResponse = await Promise.all(txDataPromises);
- const parsed = parseTxData(BCH, txDataPromiseResponse, publicKeys);
+ const parsed = parseTxData(
+ BCH,
+ txDataPromiseResponse,
+ publicKeys,
+ wallet,
+ );
return parsed;
} catch (err) {
@@ -1054,6 +1118,60 @@
}
};
+ const getRecipientPublicKey = async (BCH, recipientAddress) => {
+ let recipientPubKey;
+ try {
+ recipientPubKey = await getPublicKey(BCH, recipientAddress);
+ } catch (err) {
+ console.log(`useBCH.getRecipientPublicKey() error: ` + err);
+ throw err;
+ }
+ return recipientPubKey;
+ };
+
+ const handleEncryptedOpReturn = async (
+ BCH,
+ destinationAddress,
+ optionalOpReturnMsg,
+ ) => {
+ let recipientPubKey, encryptedEj;
+ try {
+ recipientPubKey = await getRecipientPublicKey(
+ BCH,
+ destinationAddress,
+ );
+ } catch (err) {
+ console.log(`useBCH.handleEncryptedOpReturn() error: ` + err);
+ throw err;
+ }
+
+ if (recipientPubKey === 'not found') {
+ // if the API can't find a pub key, it is due to the wallet having no outbound tx
+ throw new Error(
+ 'Cannot send an encrypted message to a wallet with no outgoing transactions',
+ );
+ }
+
+ try {
+ const pubKeyBuf = Buffer.from(recipientPubKey, 'hex');
+ const bufferedFile = Buffer.from(optionalOpReturnMsg);
+ const structuredEj = await ecies.encrypt(pubKeyBuf, bufferedFile);
+
+ // Serialize the encrypted data object
+ encryptedEj = Buffer.concat([
+ structuredEj.epk,
+ structuredEj.iv,
+ structuredEj.ct,
+ structuredEj.mac,
+ ]);
+ } catch (err) {
+ console.log(`useBCH.handleEncryptedOpReturn() error: ` + err);
+ throw err;
+ }
+
+ return encryptedEj;
+ };
+
const sendXec = async (
BCH,
wallet,
@@ -1064,6 +1182,7 @@
destinationAddressAndValueArray,
destinationAddress,
sendAmount,
+ encryptionFlag,
) => {
try {
let value = new BigNumber(0);
@@ -1143,20 +1262,48 @@
throw error;
}
+ let script;
// Start of building the OP_RETURN output.
// only build the OP_RETURN output if the user supplied it
if (
+ optionalOpReturnMsg &&
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 encryptedEj;
+ try {
+ encryptedEj = await handleEncryptedOpReturn(
+ BCH,
+ destinationAddress,
+ optionalOpReturnMsg,
+ );
+ } catch (err) {
+ console.log(`useBCH.sendXec() encryption error.`);
+ throw err;
+ }
+
+ // build the OP_RETURN script with the encryption prefix
+ script = [
+ BCH.Script.opcodes.OP_RETURN, // 6a
+ Buffer.from(
+ currency.opReturn.appPrefixesHex.cashtabEncrypted,
+ 'hex',
+ ), // 65746162
+ 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);
}
@@ -1313,5 +1460,7 @@
sendToken,
createToken,
getTokenStats,
+ handleEncryptedOpReturn,
+ getRecipientPublicKey,
};
}
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
@@ -247,7 +247,12 @@
const txHistory = await getTxHistory(BCH, cashAddresses);
// public keys are used to determined if a tx is incoming outgoing
- const parsedTxHistory = await getTxData(BCH, txHistory, publicKeys);
+ const parsedTxHistory = await getTxData(
+ BCH,
+ txHistory,
+ publicKeys,
+ wallet,
+ );
const parsedWithTokens = await addTokenTxData(BCH, parsedTxHistory);
diff --git a/web/cashtab/src/utils/cashMethods.js b/web/cashtab/src/utils/cashMethods.js
--- a/web/cashtab/src/utils/cashMethods.js
+++ b/web/cashtab/src/utils/cashMethods.js
@@ -310,16 +310,19 @@
// then it's an eToken tx that has not been properly validated
// Do not include it in nonEtokenUtxos
// App will ignore it until SLPDB is able to validate it
+ /*
console.log(
`utxo ${thisUtxoTxid} requires further eToken validation, ignoring`,
- );
+ );*/
} else {
// Otherwise it's just an OP_RETURN tx that SLPDB has some issue with
// It should still be in the user's utxo set
// Include it in nonEtokenUtxos
+ /*
console.log(
`utxo ${thisUtxoTxid} is not an eToken tx, adding to nonSlpUtxos`,
);
+ */
nonEtokenUtxos.push(thisUtxoTxid);
}
}
@@ -328,6 +331,98 @@
return nonEtokenUtxos;
};
+/* Converts a serialized buffer containing encrypted data into an object
+ * that can be interpreted by the ecies-lite library.
+ *
+ * For reference on the parsing logic in this function refer to the link below on the segment of
+ * ecies-lite's encryption function where the encKey, macKey, iv and cipher are sliced and concatenated
+ * https://github.com/tibetty/ecies-lite/blob/8fd97e80b443422269d0223ead55802378521679/index.js#L46-L55
+ *
+ * A similar PSF implmentation can also be found at:
+ * https://github.com/Permissionless-Software-Foundation/bch-encrypt-lib/blob/master/lib/encryption.js
+ *
+ * For more detailed overview on the ecies encryption scheme, see https://cryptobook.nakov.com/asymmetric-key-ciphers/ecies-public-key-encryption
+ */
+export const convertToEncryptStruct = encryptionBuffer => {
+ // based on ecies-lite's encryption logic, the encryption buffer is concatenated as follows:
+ // [ epk + iv + ct + mac ] whereby:
+ // - The first 32 or 64 chars of the encryptionBuffer is the epk
+ // - Both iv and ct params are 16 chars each, hence their combined substring is 32 chars from the end of the epk string
+ // - within this combined iv/ct substring, the first 16 chars is the iv param, and ct param being the later half
+ // - The mac param is appended to the end of the encryption buffer
+
+ // validate input buffer
+ if (!encryptionBuffer) {
+ throw new Error(
+ 'cashmethods.convertToEncryptStruct() error: input must be a buffer',
+ );
+ }
+
+ try {
+ // variable tracking the starting char position for string extraction purposes
+ let startOfBuf = 0;
+
+ // *** epk param extraction ***
+ // The first char of the encryptionBuffer indicates the type of the public key
+ // If the first char is 4, then the public key is 64 chars
+ // If the first char is 3 or 2, then the public key is 32 chars
+ // Otherwise this is not a valid encryption buffer compatible with the ecies-lite library
+ let publicKey;
+ switch (encryptionBuffer[0]) {
+ case 4:
+ publicKey = encryptionBuffer.slice(0, 65); // extract first 64 chars as public key
+ break;
+ case 3:
+ case 2:
+ publicKey = encryptionBuffer.slice(0, 33); // extract first 32 chars as public key
+ break;
+ default:
+ throw new Error(`Invalid type: ${encryptionBuffer[0]}`);
+ }
+
+ // *** iv and ct param extraction ***
+ startOfBuf += publicKey.length; // sets the starting char position to the end of the public key (epk) in order to extract subsequent iv and ct substrings
+ const encryptionTagLength = 32; // the length of the encryption tag (i.e. mac param) computed from each block of ciphertext, and is used to verify no one has tampered with the encrypted data
+ const ivCtSubstring = encryptionBuffer.slice(
+ startOfBuf,
+ encryptionBuffer.length - encryptionTagLength,
+ ); // extract the substring containing both iv and ct params, which is after the public key but before the mac param i.e. the 'encryption tag'
+ const ivbufParam = ivCtSubstring.slice(0, 16); // extract the first 16 chars of substring as the iv param
+ const ctbufParam = ivCtSubstring.slice(16); // extract the last 16 chars as substring the ct param
+
+ // *** mac param extraction ***
+ const macParam = encryptionBuffer.slice(
+ encryptionBuffer.length - encryptionTagLength,
+ encryptionBuffer.length,
+ ); // extract the mac param appended to the end of the buffer
+
+ return {
+ iv: ivbufParam,
+ epk: publicKey,
+ ct: ctbufParam,
+ mac: macParam,
+ };
+ } catch (err) {
+ console.error(`useBCH.convertToEncryptStruct() error: `, err);
+ throw err;
+ }
+};
+
+export const getPublicKey = async (BCH, address) => {
+ try {
+ const publicKey = await BCH.encryption.getPubKey(address);
+ return publicKey.publicKey;
+ } catch (err) {
+ if (err['error'] === 'No transaction history.') {
+ throw new Error(
+ 'Cannot send an encrypted message to a wallet with no outgoing transactions',
+ );
+ } else {
+ throw err;
+ }
+ }
+};
+
export const isLegacyMigrationRequired = wallet => {
// If the wallet does not have Path1899,
// Or each Path1899, Path145, Path245 does not have a public key

File Metadata

Mime Type
text/plain
Expires
Sat, Apr 26, 10:18 (1 h, 37 m)
Storage Engine
blob
Storage Format
Raw Data
Storage Handle
5573227
Default Alt Text
D10692.id.diff (47 KB)

Event Timeline