diff --git a/app/actions/TrezorActions.js b/app/actions/TrezorActions.js index 24ae084b02..6934f4f469 100644 --- a/app/actions/TrezorActions.js +++ b/app/actions/TrezorActions.js @@ -76,6 +76,7 @@ export const loadDeviceList = () => (dispatch, getState) => { if (!currentDevice) { // first device connected. Use it. dispatch({ device, type: TRZ_SELECTEDDEVICE_CHANGED }); + setDeviceListeners(device, dispatch); } }); @@ -111,6 +112,127 @@ export const selectDevice = (path) => async (dispatch, getState) => { const devList = getState().trezor.deviceList; if (!devList.devices[path]) return; dispatch({ device: devList.devices[path], type: TRZ_SELECTEDDEVICE_CHANGED }); + setDeviceListeners(devList.devices[path], dispatch); +}; + +export const TRZ_PIN_REQUESTED = "TRZ_PIN_REQUESTED"; +export const TRZ_PIN_ENTERED = "TRZ_PIN_ENTERED"; +export const TRZ_PIN_CANCELED = "TRZ_PIN_CANCELED"; +export const TRZ_PASSPHRASE_REQUESTED = "TRZ_PASSPHRASE_REQUESTED"; +export const TRZ_PASSPHRASE_ENTERED = "TRZ_PASSPHRASE_ENTERED"; +export const TRZ_PASSPHRASE_CANCELED = "TRZ_PASSPHRASE_CANCELED"; + +function setDeviceListeners(device, dispatch) { + device.on("pin", (pinMessage, pinCallBack) => { + dispatch({ pinMessage, pinCallBack, type: TRZ_PIN_REQUESTED }); + }); + + device.on("passphrase", (passPhraseCallBack) => { + dispatch({ passPhraseCallBack, type: TRZ_PASSPHRASE_REQUESTED }); + }); +} + +// deviceRun is the main function for executing trezor operations. This handles +// cleanup for cancellations and device disconnections during mid-operation (eg: +// someone disconnected trezor while it was waiting for a pin input). +// In general, fn itself shouldn't handle errors, letting this function handle +// the common cases, which are then propagated up the call stack into fn's +// parent. +async function deviceRun(dispatch, getState, device, fn) { + + const handleError = error => { + const { trezor: { waitingForPin, waitingForPassphrase } } = getState(); + console.log("Handle error no deviceRun"); + if (waitingForPin) dispatch({ error, type: TRZ_PIN_CANCELED }); + if (waitingForPassphrase) dispatch({ error, type: TRZ_PASSPHRASE_CANCELED }); + if (error instanceof Error) { + if (error.message.includes("Inconsistent state")) { + return "Device returned inconsistent state. Disconnect and reconnect the device."; + } + } + return error; + }; + + try { + return await device.run(async session => { + try { + return await fn(session); + } catch (err) { + // doesn't seem to be reachable by trezor interruptions, but might be + // caused by fn() failing in some other way (even though it's + // recommended not to do (non-trezor) lengthy operations inside fn()) + throw handleError(err); + } + }); + } catch (outerErr) { + throw handleError(outerErr); + } +} + +export const TRZ_CANCELOPERATION_SUCCESS = "TRZ_CANCELOPERATION_SUCCESS"; +export const TRZ_CANCELOPERATION_FAILED = "TRZ_CANCELOPERATION_FAILED"; + +// Note that calling this function while no pin/passphrase operation is running +// will attempt to steal the device, cancelling operations from apps *other +// than decrediton*. +export const cancelCurrentOperation = () => async (dispatch, getState) => { + const device = selectors.trezorDevice(getState()); + const { trezor: { pinCallBack, passPhraseCallBack } } = getState(); + + if (!device) return; + try { + if (pinCallBack) await pinCallBack("cancelled", null); + else if (passPhraseCallBack) await passPhraseCallBack("cancelled", null); + else await device.steal(); + + dispatch({ type: TRZ_CANCELOPERATION_SUCCESS }); + } catch (error) { + dispatch({ error, type: TRZ_CANCELOPERATION_FAILED }); + } +}; + +export const submitPin = (pin) => (dispatch, getState) => { + const { trezor: { pinCallBack } } = getState(); + dispatch({ type: TRZ_PIN_ENTERED }); + if (!pinCallBack) return; + pinCallBack(null, pin); +}; + +export const submitPassPhrase = (passPhrase) => (dispatch, getState) => { + const { trezor: { passPhraseCallBack } } = getState(); + dispatch({ type: TRZ_PASSPHRASE_ENTERED }); + if (!passPhraseCallBack) return; + passPhraseCallBack(null, passPhrase); +}; + +// checkTrezorIsDcrwallet verifies whether the wallet currently running on +// dcrwallet (presumably a watch only wallet created from a trezor provided +// xpub) is the same wallet as the one of the currently connected trezor. This +// function throws an error if they are not the same. +// This is useful for making sure, prior to performing some wallet related +// function such as transaction signing, that trezor will correctly perform the +// operation. +// Note that this might trigger pin/passphrase modals, depending on the current +// trezor configuration. +// The way the check is performed is by generating the first address from the +// trezor wallet and then validating this address agains dcrwallet, ensuring +// this is an owned address at the appropriate branch/index. +// This check is only valid for a single session (ie, a single execution of +// `deviceRun`) as the physical device might change between sessions. +const checkTrezorIsDcrwallet = (session) => async (dispatch, getState) => { + const { grpc: { walletService } } = getState(); + const chainParams = selectors.chainParams(getState()); + + const address_n = addressPath(0, 0, WALLET_ACCOUNT, chainParams.HDCoinType); + const resp = await session.getAddress(address_n, chainParams.trezorCoinName, false); + const addr = resp.message.address; + + const addrValidResp = await wallet.validateAddress(walletService, addr); + if (!addrValidResp.getIsValid()) throw "Trezor provided an invalid address " + addr; + + if (!addrValidResp.getIsMine()) throw "Trezor and dcrwallet not running from the same extended public key"; + + if (addrValidResp.getIndex() !== 0) throw "Wallet replied with wrong index."; }; export const signTransactionAttemptTrezor = (rawUnsigTx, constructTxResponse) => async (dispatch, getState) => { @@ -120,7 +242,10 @@ export const signTransactionAttemptTrezor = (rawUnsigTx, constructTxResponse) => const chainParams = selectors.chainParams(getState()); const device = selectors.trezorDevice(getState()); - // TODO: handle not having device + if (!device) { + dispatch({ error: "Device not connected", type: SIGNTX_FAILED }); + return; + } console.log("construct tx response", constructTxResponse); @@ -135,7 +260,9 @@ export const signTransactionAttemptTrezor = (rawUnsigTx, constructTxResponse) => const txInfo = await dispatch(walletTxToBtcjsTx(decodedUnsigTx, changeIndex, inputTxs)); - const signedRaw = await device.run(async session => { + const signedRaw = await deviceRun(dispatch, getState, device, async session => { + await dispatch(checkTrezorIsDcrwallet(session)); + const signedResp = await session.signTx(txInfo.inputs, txInfo.outputs, refTxs, chainParams.trezorCoinName, 0); return signedResp.message.serialized.serialized_tx; @@ -196,6 +323,7 @@ export const walletTxToBtcjsTx = (tx, changeIndex, inputTxs) => async (dispatch, sequence: inp.getSequence(), address_n: addressPath(addrIndex, addrBranch, WALLET_ACCOUNT, chainParams.HDCoinType), + decred_tree: inp.getTree() // FIXME: this needs to be supported on trezor.js. // decredTree: inp.getTree(), @@ -228,6 +356,7 @@ export const walletTxToBtcjsTx = (tx, changeIndex, inputTxs) => async (dispatch, script_type: "PAYTOADDRESS", // needs to change on OP_RETURNs address: addr, address_n: address_n, + decred_script_version: outp.getVersion(), }); } @@ -249,6 +378,7 @@ export function walletTxToRefTx(tx) { amount: inp.getAmountIn(), prev_hash: rawHashToHex(inp.getPreviousTransactionHash()), prev_index: inp.getPreviousTransactionIndex(), + decred_tree: inp.getTree() // TODO: this needs to be supported on trezor.js // decredTree: inp.getTree(), @@ -258,6 +388,7 @@ export function walletTxToRefTx(tx) { const bin_outputs = tx.getOutputsList().map(outp => ({ amount: outp.getValue(), script_pubkey: rawToHex(outp.getScript()), + decred_script_version: outp.getVersion(), })); const txInfo = { diff --git a/app/components/modals/trezor/Modals.js b/app/components/modals/trezor/Modals.js new file mode 100644 index 0000000000..a391fc9eda --- /dev/null +++ b/app/components/modals/trezor/Modals.js @@ -0,0 +1,36 @@ +import { trezor } from "connectors"; +import PinModal from "./PinModal"; +import PassPhraseModal from "./PassPhraseModal"; +import "style/Trezor.less"; + +@autobind +class TrezorModals extends React.Component { + constructor(props) { + super(props); + } + + onCancelModal() { + this.props.cancelCurrentOperation(); + } + + render() { + if (this.props.waitingForPin) { + return ; + } else if (this.props.waitingForPassPhrase) { + return ; + } else { + return null; + } + } +} + +const TrezorModalsOrNone = ({ isTrezor, ...props }) => + isTrezor ? : null; + +export default trezor(TrezorModalsOrNone); diff --git a/app/components/modals/trezor/PassPhraseModal.js b/app/components/modals/trezor/PassPhraseModal.js new file mode 100644 index 0000000000..8825143411 --- /dev/null +++ b/app/components/modals/trezor/PassPhraseModal.js @@ -0,0 +1,38 @@ +import { PassphraseModal } from "../PassphraseModal"; +import { FormattedMessage as T } from "react-intl"; + +@autobind +class TrezorPassphraseModal extends React.Component { + constructor(props) { + super(props); + } + + onSubmit(passPhrase) { + console.log("gonna submit", passPhrase); + this.props.submitPassPhrase(passPhrase); + } + + render() { + const { onCancelModal } = this.props; + const { onSubmit } = this; + + const trezorLabel = this.props.device ? this.props.device.features.label : ""; + + return ( + } + className="trezor-passphrase-modal" + modalDescription={ +

+ '{trezorLabel}' }} /> +

+ } + {...{ onCancelModal, onSubmit }} + /> + ); + } +} + +export default TrezorPassphraseModal; diff --git a/app/components/modals/trezor/PinModal.js b/app/components/modals/trezor/PinModal.js new file mode 100644 index 0000000000..eb33044f42 --- /dev/null +++ b/app/components/modals/trezor/PinModal.js @@ -0,0 +1,75 @@ +import Modal from "../Modal"; +import { FormattedMessage as T } from "react-intl"; +import { PasswordInput } from "inputs"; +import { ButtonsToolbar } from "../PassphraseModal"; +import { InvisibleButton } from "buttons"; + +const PinButton = ({ index, label, onClick }) => +
onClick(index)}>{label}
; + +@autobind +class PinModal extends React.Component { + constructor(props) { + super(props); + this.state = { currentPin: "" }; + } + + onPinButtonClick(index) { + this.setState({ currentPin: this.state.currentPin + index }); + } + + onCancelModal() { + this.setState({ currentPin: "" }); + this.props.onCancelModal(); + } + + onSubmit() { + this.props.submitPin(this.state.currentPin); + } + + onClearPin() { + this.setState({ currentPin: "" }); + } + + render() { + const { onCancelModal, onSubmit, onPinButtonClick, onClearPin } = this; + + const labels = "ABCDEFGHI"; + const currentPin = this.state.currentPin.split("").map(v => labels[parseInt(v)-1]).join(""); + + const Button = ({ index }) => + ; + + const trezorLabel = this.props.device ? this.props.device.features.label : ""; + + return ( + +

+

'{trezorLabel}' }} />

+
+
+
+ + + +
+
+ +
+ +
+ ); + } +} + +export default PinModal; diff --git a/app/components/modals/trezor/index.js b/app/components/modals/trezor/index.js new file mode 100644 index 0000000000..a4848bf877 --- /dev/null +++ b/app/components/modals/trezor/index.js @@ -0,0 +1 @@ +export { default as TrezorModals } from "./Modals"; diff --git a/app/connectors/index.js b/app/connectors/index.js index 93bb186e07..68881bda90 100644 --- a/app/connectors/index.js +++ b/app/connectors/index.js @@ -52,3 +52,4 @@ export { default as preVoteProposals } from "./preVoteProposals"; export { default as votedProposals } from "./votedProposals"; export { default as proposals } from "./proposals"; export { default as network } from "./network"; +export { default as trezor } from "./trezor"; diff --git a/app/connectors/trezor.js b/app/connectors/trezor.js new file mode 100644 index 0000000000..52429e017e --- /dev/null +++ b/app/connectors/trezor.js @@ -0,0 +1,20 @@ +import { connect } from "react-redux"; +import { selectorMap } from "../fp"; +import { bindActionCreators } from "redux"; +import * as sel from "../selectors"; +import * as trza from "../actions/TrezorActions"; + +const mapStateToProps = selectorMap({ + isTrezor: sel.isTrezor, + waitingForPin: sel.trezorWaitingForPin, + waitingForPassPhrase: sel.trezorWaitingForPassPhrase, + device: sel.trezorDevice, +}); + +const mapDispatchToProps = dispatch => bindActionCreators({ + cancelCurrentOperation: trza.cancelCurrentOperation, + submitPin: trza.submitPin, + submitPassPhrase: trza.submitPassPhrase, +}, dispatch); + +export default connect(mapStateToProps, mapDispatchToProps); diff --git a/app/containers/App.js b/app/containers/App.js index 51092be403..21827efb56 100644 --- a/app/containers/App.js +++ b/app/containers/App.js @@ -10,6 +10,7 @@ import ShutdownAppPage from "components/views/ShutdownAppPage"; import FatalErrorPage from "components/views/FatalErrorPage"; import Snackbar from "components/Snackbar"; import { log } from "wallet"; +import { TrezorModals } from "components/modals/trezor"; import "style/Layout.less"; const topLevelAnimation = { atEnter: { opacity: 0 }, atLeave: { opacity: 0 }, atActive: { opacity: 1 } }; @@ -104,6 +105,7 @@ class App extends React.Component {