diff --git a/.travis.yml b/.travis.yml index dee00d488..c7e786584 100644 --- a/.travis.yml +++ b/.travis.yml @@ -27,7 +27,6 @@ before_install: - ./assets/script/install_lnd.sh script: - - npm audit - npm test - npm run build-storybook diff --git a/mobile/main.js b/mobile/main.js index 9ecb7d5be..24c30dda7 100644 --- a/mobile/main.js +++ b/mobile/main.js @@ -1,14 +1,19 @@ +/** + * @fileOverview the main module that wires up all dependencies for mobile. + */ + import React from 'react'; -import { Clipboard } from 'react-native'; -import { createStackNavigator } from 'react-navigation'; +import { Clipboard, Alert } from 'react-native'; +import { SecureStore, LocalAuthentication } from 'expo'; +import { createStackNavigator, NavigationActions } from 'react-navigation'; import FontLoader from './component/font-loader'; -import SetPasswordView from '../src/view/set-password-mobile'; -import SetPasswordConfirmView from '../src/view/set-password-confirm-mobile'; +import SetPinView from '../src/view/set-pin-mobile'; +import SetPinConfirmView from '../src/view/set-pin-confirm-mobile'; import SeedSuccessView from '../src/view/seed-success'; import NewAddressView from '../src/view/new-address'; -import PasswordView from '../src/view/password-mobile'; +import PinView from '../src/view/pin-mobile'; import WaitView from '../src/view/wait-mobile'; import HomeView from '../src/view/home'; import SettingView from '../src/view/setting'; @@ -34,10 +39,11 @@ import InvoiceAction from '../src/action/invoice'; import PaymentAction from '../src/action/payment'; import ChannelAction from '../src/action/channel'; import TransactionAction from '../src/action/transaction'; +import AuthAction from '../src/action/auth-mobile'; const store = new Store(); store.init(); -const nav = new NavAction(store); +const nav = new NavAction(store, NavigationActions); const db = sinon.createStubInstance(AppStorage); const ipc = sinon.createStubInstance(IpcAction); const grpc = sinon.createStubInstance(GrpcAction); @@ -61,12 +67,19 @@ sinon.stub(channel, 'update'); sinon.stub(channel, 'connectAndOpen'); sinon.stub(channel, 'closeSelectedChannel'); -const SetPassword = () => ( - +const auth = new AuthAction( + store, + wallet, + nav, + SecureStore, + LocalAuthentication, + Alert ); -const SetPasswordConfirm = () => ( - +const SetPin = () => ; + +const SetPinConfirm = () => ( + ); const SeedSuccess = () => ; @@ -79,7 +92,7 @@ const NewAddress = () => ( /> ); -const Password = () => ; +const Pin = () => ; const Wait = () => ; @@ -119,11 +132,11 @@ const Pay = () => ; const MainStack = createStackNavigator( { - SetPassword, - SetPasswordConfirm, + SetPin, + SetPinConfirm, SeedSuccess, NewAddress, - Password, + Pin, Wait, Home, Settings, diff --git a/mobile/rn-cli.config.js b/mobile/rn-cli.config.js index f7e9066a1..40f21c1b7 100644 --- a/mobile/rn-cli.config.js +++ b/mobile/rn-cli.config.js @@ -4,6 +4,7 @@ module.exports = { extraNodeModules: { react: path.resolve(__dirname, 'node_modules/react'), 'react-native': path.resolve(__dirname, 'node_modules/react-native'), + expo: path.resolve(__dirname, 'node_modules/expo'), 'prop-types': path.resolve(__dirname, 'node_modules/prop-types'), 'react-native-svg': path.resolve( __dirname, diff --git a/src/action/auth-mobile.js b/src/action/auth-mobile.js new file mode 100644 index 000000000..140a098b2 --- /dev/null +++ b/src/action/auth-mobile.js @@ -0,0 +1,202 @@ +/** + * @fileOverview action to handle mobile specific authentication + * using PINs, TouchID, and KeyStore storage. + */ + +import { PIN_LENGTH } from '../config'; + +const PIN = 'DevicePin'; +const PASS = 'WalletPassword'; + +class AuthAction { + constructor(store, wallet, nav, SecureStore, Fingerprint, Alert) { + this._store = store; + this._wallet = wallet; + this._nav = nav; + this._SecureStore = SecureStore; + this._Fingerprint = Fingerprint; + this._Alert = Alert; + } + + // + // PIN actions + // + + /** + * Initialize the set pin view by resetting input values + * and then navigating to the view. + * @return {undefined} + */ + initSetPin() { + this._store.auth.newPin = ''; + this._store.auth.pinVerify = ''; + this._nav.goSetPin(); + } + + /** + * Initialize the pin view by resetting input values + * and then navigating to the view. + * @return {undefined} + */ + initPin() { + this._store.auth.pin = ''; + this._nav.goPin(); + } + + /** + * Append a digit input to the pin parameter. + * @param {string} options.digit The digit to append to the pin + * @param {string} options.param The pin parameter name + * @return {undefined} + */ + pushPinDigit({ digit, param }) { + const { auth } = this._store; + if (auth[param].length < PIN_LENGTH) { + auth[param] += digit; + } + if (auth[param].length < PIN_LENGTH) { + return; + } + if (param === 'newPin') { + this._nav.goSetPinConfirm(); + } else if (param === 'pinVerify') { + this.checkNewPin(); + } else if (param === 'pin') { + this.checkPin(); + } + } + + /** + * Remove the last digit from the pin parameter. + * @param {string} options.param The pin parameter name + * @return {undefined} + */ + popPinDigit({ param }) { + const { auth } = this._store; + if (auth[param]) { + auth[param] = auth[param].slice(0, -1); + } else if (param === 'pinVerify') { + this.initSetPin(); + } + } + + /** + * Check the PIN that was chosen by the user was entered + * correctly twice to make sure that there was no typo. + * If everything is ok, store the pin in the keystore and + * unlock the wallet. + * @return {Promise} + */ + async checkNewPin() { + const { newPin, pinVerify } = this._store.auth; + if (newPin.length !== PIN_LENGTH || newPin !== pinVerify) { + this._alert("PINs don't match", () => this.initSetPin()); + return; + } + await this._setToKeyStore(PIN, newPin); + await this._generateWalletPassword(); + } + + /** + * Check the PIN that was entered by the user in the unlock + * screen matches the pin stored in the keystore and unlock + * the wallet. + * @return {Promise} + */ + async checkPin() { + const { pin } = this._store.auth; + const storedPin = await this._getFromKeyStore(PIN); + if (pin !== storedPin) { + this._alert('Incorrect PIN', () => this.initPin()); + return; + } + await this._unlockWallet(); + } + + // + // TouchID & KeyStore Authentication + // + + /** + * Try authenticating the user using either via TouchID/FaceID on iOS + * or a fingerprint reader on Android. + * @return {Promise} + */ + async tryFingerprint() { + const hasHardware = await this._Fingerprint.hasHardwareAsync(); + if (!hasHardware) { + return; + } + const msg = 'Unlock your Wallet'; + const { success } = await this._Fingerprint.authenticateAsync(msg); + if (!success) { + return; + } + await this._unlockWallet(); + } + + /** + * A new wallet password is generated and stored in the keystore + * during device setup. This password is not intended to be displayed + * to the user but is unlocked at the application layer via TouchID + * or PIN (which is stored in the keystore). + * @return {Promise} + */ + async _generateWalletPassword() { + const newPass = this._totallyNotSecureRandomPassword(); + await this._setToKeyStore(PASS, newPass); + this._store.wallet.newPassword = newPass; + this._store.wallet.passwordVerify = newPass; + await this._wallet.checkNewPassword(); + } + + /** + * Unlock the wallet using a randomly generated password that is + * stored in the keystore. This password is not intended to be displayed + * to the user but rather unlocked at the application layer. + * @return {Promise} + */ + async _unlockWallet() { + const storedPass = await this._getFromKeyStore(PASS); + this._store.wallet.password = storedPass; + await this._wallet.checkPassword(); + } + + _getFromKeyStore(key) { + const options = { + keychainAccessible: this._SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY, + }; + return this._SecureStore.getItemAsync(key, options); + } + + _setToKeyStore(key, value) { + const options = { + keychainAccessible: this._SecureStore.WHEN_UNLOCKED_THIS_DEVICE_ONLY, + }; + return this._SecureStore.setItemAsync(key, value, options); + } + + _alert(title, callback) { + this._Alert.alert(title, '', [{ text: 'TRY AGAIN', onPress: callback }]); + } + + /** + * NOT SECURE ... DO NOT USE IN PRODUCTION !!! + * + * Just a stop gap during development until we have a secure native + * PRNG: https://github.com/lightninglabs/lightning-app/issues/777 + * + * Generate a hex encoded 256 bit entropy wallet password (which will + * be stretched using a KDF in lnd). + * @return {string} A hex string containing some random bytes + */ + _totallyNotSecureRandomPassword() { + const bytes = new Uint8Array(32); + for (let i = 0; i < bytes.length; i++) { + bytes[i] = Math.floor(256 * Math.random()); + } + return Buffer.from(bytes.buffer).toString('hex'); + } +} + +export default AuthAction; diff --git a/src/action/nav-mobile.js b/src/action/nav-mobile.js index 90d2f399c..eacbe1832 100644 --- a/src/action/nav-mobile.js +++ b/src/action/nav-mobile.js @@ -4,16 +4,15 @@ * and only change the route to be rendered in the user interface. */ -import { NavigationActions } from 'react-navigation'; - class NavAction { - constructor(store) { + constructor(store, Navigation) { this._store = store; + this._Navigation = Navigation; } setTopLevelNavigator(navigatorRef) { this._navigate = (routeName, params) => - navigatorRef.dispatch(NavigationActions.navigate({ routeName, params })); + navigatorRef.dispatch(this._Navigation.navigate({ routeName, params })); } goLoader() { @@ -36,24 +35,24 @@ class NavAction { this._navigate('RestoreSeed'); } - goRestorePassword() { - this._navigate('RestorePassword'); + goRestorePin() { + this._navigate('RestorePin'); } goSeedSuccess() { this._navigate('SeedSuccess'); } - goSetPassword() { - this._navigate('SetPassword'); + goSetPin() { + this._navigate('SetPin'); } - goSetPasswordConfirm() { - this._navigate('SetPasswordConfirm'); + goSetPinConfirm() { + this._navigate('SetPinConfirm'); } - goPassword() { - this._navigate('Password'); + goPin() { + this._navigate('Pin'); } goNewAddress() { diff --git a/src/action/wallet.js b/src/action/wallet.js index 6903eaf08..9b2729f81 100644 --- a/src/action/wallet.js +++ b/src/action/wallet.js @@ -9,7 +9,6 @@ import { NOTIFICATION_DELAY, RATE_DELAY, RECOVERY_WINDOW, - PIN_LENGTH, } from '../config'; import { when } from 'mobx'; import * as log from './log'; @@ -118,43 +117,6 @@ class WalletAction { this._store.wallet.passwordVerify = password; } - /** - * Append a digit input to the password parameter. - * @param {string} options.digit The digit to append to the password - * @param {string} options.param The password parameter name - * @return {undefined} - */ - pushPinDigit({ digit, param }) { - const { wallet } = this._store; - if (wallet[param].length < PIN_LENGTH) { - wallet[param] += digit; - } - if (wallet[param].length < PIN_LENGTH) { - return; - } - if (param === 'newPassword') { - this._nav.goSetPasswordConfirm(); - } else if (param === 'passwordVerify') { - this.checkNewPassword(PIN_LENGTH); - } else if (param === 'password') { - this.checkPassword(); - } - } - - /** - * Remove the last digit from the password parameter. - * @param {string} options.param The password parameter name - * @return {undefined} - */ - popPinDigit({ param }) { - const { wallet } = this._store; - if (wallet[param]) { - wallet[param] = wallet[param].slice(0, -1); - } else if (param === 'passwordVerify') { - this.initSetPassword(); - } - } - /** * Set whether or not we're restoring the wallet. * @param {boolean} options.restoring Whether or not we're restoring. @@ -240,11 +202,11 @@ class WalletAction { * there was no typo. * @return {Promise} */ - async checkNewPassword(minLength = MIN_PASSWORD_LENGTH) { + async checkNewPassword() { const { newPassword, passwordVerify } = this._store.wallet; let errorMsg; - if (!newPassword || newPassword.length < minLength) { - errorMsg = `Set a password with at least ${minLength} characters.`; + if (!newPassword || newPassword.length < MIN_PASSWORD_LENGTH) { + errorMsg = `Set a password with at least ${MIN_PASSWORD_LENGTH} characters.`; } else if (newPassword !== passwordVerify) { errorMsg = 'Passwords do not match!'; } diff --git a/src/store.js b/src/store.js index 2b3857402..9b6ad3d9b 100644 --- a/src/store.js +++ b/src/store.js @@ -36,6 +36,11 @@ export class Store { pubKey: null, walletAddress: null, displayCopied: false, + auth: { + pin: '', + newPin: '', + pinVerify: '', + }, wallet: { password: '', newPassword: '', diff --git a/src/view/password-mobile.js b/src/view/password-mobile.js deleted file mode 100644 index 7db3d5bc7..000000000 --- a/src/view/password-mobile.js +++ /dev/null @@ -1,59 +0,0 @@ -import React from 'react'; -import { View, StyleSheet } from 'react-native'; -import { observer } from 'mobx-react'; -import PropTypes from 'prop-types'; -import Background from '../component/background'; -import MainContent from '../component/main-content'; -import BoltIcon from '../asset/icon/lightning-bolt'; -import LightningWord from '../asset/icon/lightning-word'; -import { Text } from '../component/text'; -import { FormStretcher } from '../component/form'; -import { PinBubbles, PinKeyboard } from '../component/pin-entry'; - -// -// Password View (Mobile) -// - -const styles = StyleSheet.create({ - content: { - paddingLeft: 20, - paddingRight: 20, - }, - boltWrapper: { - marginTop: 50, - }, - wordWrapper: { - marginTop: 35, - }, - bubbles: { - marginTop: 10, - }, -}); - -const PasswordView = ({ store, wallet }) => ( - - - - - - - - - - Unlock with your pin - - - wallet.pushPinDigit({ digit, param: 'password' })} - onBackspace={() => wallet.popPinDigit({ param: 'password' })} - /> - - -); - -PasswordView.propTypes = { - store: PropTypes.object.isRequired, - wallet: PropTypes.object.isRequired, -}; - -export default observer(PasswordView); diff --git a/src/view/pin-mobile.js b/src/view/pin-mobile.js new file mode 100644 index 000000000..f56c572e7 --- /dev/null +++ b/src/view/pin-mobile.js @@ -0,0 +1,68 @@ +import React from 'react'; +import { View, StyleSheet } from 'react-native'; +import { observer } from 'mobx-react'; +import PropTypes from 'prop-types'; +import Background from '../component/background'; +import MainContent from '../component/main-content'; +import BoltIcon from '../asset/icon/lightning-bolt'; +import LightningWord from '../asset/icon/lightning-word'; +import { Text } from '../component/text'; +import { FormStretcher } from '../component/form'; +import { PinBubbles, PinKeyboard } from '../component/pin-entry'; + +// +// Pin View (Mobile) +// + +const styles = StyleSheet.create({ + content: { + paddingLeft: 20, + paddingRight: 20, + }, + boltWrapper: { + marginTop: 50, + }, + wordWrapper: { + marginTop: 35, + }, + bubbles: { + marginTop: 10, + }, +}); + +class PinView extends React.Component { + componentDidMount() { + this.props.auth.tryFingerprint(); + } + + render() { + const { store, auth } = this.props; + return ( + + + + + + + + + + Unlock with your pin + + + auth.pushPinDigit({ digit, param: 'pin' })} + onBackspace={() => auth.popPinDigit({ param: 'pin' })} + /> + + + ); + } +} + +PinView.propTypes = { + store: PropTypes.object.isRequired, + auth: PropTypes.object.isRequired, +}; + +export default observer(PinView); diff --git a/src/view/set-password-confirm-mobile.js b/src/view/set-pin-confirm-mobile.js similarity index 71% rename from src/view/set-password-confirm-mobile.js rename to src/view/set-pin-confirm-mobile.js index 9ffc043e1..e72e01780 100644 --- a/src/view/set-password-confirm-mobile.js +++ b/src/view/set-pin-confirm-mobile.js @@ -9,7 +9,7 @@ import { FormStretcher } from '../component/form'; import { PinBubbles, PinKeyboard } from '../component/pin-entry'; // -// Set Password Confirm View (Mobile) +// Set Pin Confirm View (Mobile) // const styles = StyleSheet.create({ @@ -27,7 +27,7 @@ const styles = StyleSheet.create({ }, }); -const SetPasswordConfirmView = ({ store, wallet }) => ( +const SetPinConfirmView = ({ store, auth }) => ( Re-type PIN @@ -35,21 +35,19 @@ const SetPasswordConfirmView = ({ store, wallet }) => ( {"Type your PIN again to make sure it's the correct one."} - + - wallet.pushPinDigit({ digit, param: 'passwordVerify' }) - } - onBackspace={() => wallet.popPinDigit({ param: 'passwordVerify' })} + onInput={digit => auth.pushPinDigit({ digit, param: 'pinVerify' })} + onBackspace={() => auth.popPinDigit({ param: 'pinVerify' })} /> ); -SetPasswordConfirmView.propTypes = { +SetPinConfirmView.propTypes = { store: PropTypes.object.isRequired, - wallet: PropTypes.object.isRequired, + auth: PropTypes.object.isRequired, }; -export default observer(SetPasswordConfirmView); +export default observer(SetPinConfirmView); diff --git a/src/view/set-password-mobile.js b/src/view/set-pin-mobile.js similarity index 72% rename from src/view/set-password-mobile.js rename to src/view/set-pin-mobile.js index 071493ff9..8874d18c6 100644 --- a/src/view/set-password-mobile.js +++ b/src/view/set-pin-mobile.js @@ -9,7 +9,7 @@ import { FormStretcher } from '../component/form'; import { PinBubbles, PinKeyboard } from '../component/pin-entry'; // -// Set Password View (Mobile) +// Set Pin View (Mobile) // const styles = StyleSheet.create({ @@ -27,7 +27,7 @@ const styles = StyleSheet.create({ }, }); -const SetPasswordView = ({ store, wallet }) => ( +const SetPinView = ({ store, auth }) => ( Set PIN @@ -35,19 +35,19 @@ const SetPasswordView = ({ store, wallet }) => ( Type the PIN you want to use to unlock your wallet. - + wallet.pushPinDigit({ digit, param: 'newPassword' })} - onBackspace={() => wallet.popPinDigit({ param: 'newPassword' })} + onInput={digit => auth.pushPinDigit({ digit, param: 'newPin' })} + onBackspace={() => auth.popPinDigit({ param: 'newPin' })} /> ); -SetPasswordView.propTypes = { +SetPinView.propTypes = { store: PropTypes.object.isRequired, - wallet: PropTypes.object.isRequired, + auth: PropTypes.object.isRequired, }; -export default observer(SetPasswordView); +export default observer(SetPinView); diff --git a/src/view/wait-mobile.js b/src/view/wait-mobile.js index 0b46e9ff0..f65f079a7 100644 --- a/src/view/wait-mobile.js +++ b/src/view/wait-mobile.js @@ -2,7 +2,8 @@ import React from 'react'; import { StyleSheet, ActivityIndicator } from 'react-native'; import Background from '../component/background'; import MainContent from '../component/main-content'; -import { color } from '../component/style'; +import Text from '../component/text'; +import { color, font } from '../component/style'; const styles = StyleSheet.create({ content: { @@ -11,6 +12,11 @@ const styles = StyleSheet.create({ spinner: { transform: [{ scale: 1.5 }], }, + copy: { + marginTop: 15, + fontSize: font.sizeXS, + color: color.white, + }, }); const WaitView = () => ( @@ -21,6 +27,7 @@ const WaitView = () => ( color={color.lightPurple} style={styles.spinner} /> + Loading network... ); diff --git a/stories/screen-story.js b/stories/screen-story.js index 99193e3b3..d6962de54 100644 --- a/stories/screen-story.js +++ b/stories/screen-story.js @@ -16,6 +16,7 @@ import InvoiceAction from '../src/action/invoice'; import PaymentAction from '../src/action/payment'; import ChannelAction from '../src/action/channel'; import TransactionAction from '../src/action/transaction'; +import AuthAction from '../src/action/auth-mobile'; import Welcome from '../src/view/welcome'; import Transaction from '../src/view/transaction'; import Setting from '../src/view/setting'; @@ -46,11 +47,11 @@ import SeedSuccess from '../src/view/seed-success'; import Seed from '../src/view/seed'; import SeedVerify from '../src/view/seed-verify'; import SetPassword from '../src/view/set-password'; -import SetPasswordMobile from '../src/view/set-password-mobile'; +import SetPinMobile from '../src/view/set-pin-mobile'; import SetPasswordConfirm from '../src/view/set-password-confirm'; -import SetPasswordConfirmMobile from '../src/view/set-password-confirm-mobile'; +import SetPinConfirmMobile from '../src/view/set-pin-confirm-mobile'; import Password from '../src/view/password'; -import PasswordMobile from '../src/view/password-mobile'; +import PinMobile from '../src/view/pin-mobile'; import RestorePassword from '../src/view/restore-password'; import ResetPasswordCurrent from '../src/view/reset-password-current'; import ResetPasswordNew from '../src/view/reset-password-new'; @@ -88,6 +89,12 @@ const channel = new ChannelAction(store, grpc, nav, notify); sinon.stub(channel, 'update'); sinon.stub(channel, 'connectAndOpen'); sinon.stub(channel, 'closeSelectedChannel'); +const auth = new AuthAction(store, wallet, nav); +sinon.stub(auth, 'checkNewPin'); +sinon.stub(auth, 'checkPin'); +sinon.stub(auth, 'tryFingerprint'); +sinon.stub(auth, '_unlockWallet'); +sinon.stub(auth, '_generateWalletPassword'); storiesOf('Screens', module) .add('Welcome', () => ) @@ -109,16 +116,14 @@ storiesOf('Screens', module) .add('Set Password Confirm', () => ( )) - .add('Set Password (Mobile)', () => ( - + .add('Set PIN (Mobile)', () => ( + )) - .add('Set Password Confirm (Mobile)', () => ( - + .add('Set PIN Confirm (Mobile)', () => ( + )) .add('Password', () => ) - .add('Password (Mobile)', () => ( - - )) + .add('PIN (Mobile)', () => ) .add('Restore Wallet: Password', () => ( )) diff --git a/test/unit/action/auth-mobile.spec.js b/test/unit/action/auth-mobile.spec.js new file mode 100644 index 000000000..865937a08 --- /dev/null +++ b/test/unit/action/auth-mobile.spec.js @@ -0,0 +1,218 @@ +import { Store } from '../../../src/store'; +import NavAction from '../../../src/action/nav-mobile'; +import WalletAction from '../../../src/action/wallet'; +import AuthAction from '../../../src/action/auth-mobile'; + +describe('Action AuthMobile Unit Tests', () => { + let sandbox; + let store; + let wallet; + let nav; + let auth; + let SecureStore; + let Fingerprint; + let Alert; + + beforeEach(() => { + sandbox = sinon.createSandbox({}); + store = new Store(); + wallet = sinon.createStubInstance(WalletAction); + nav = sinon.createStubInstance(NavAction); + SecureStore = { + getItemAsync: sinon.stub(), + setItemAsync: sinon.stub(), + }; + Fingerprint = { + hasHardwareAsync: sinon.stub(), + authenticateAsync: sinon.stub(), + }; + Alert = { + alert: sinon.stub(), + }; + auth = new AuthAction(store, wallet, nav, SecureStore, Fingerprint, Alert); + }); + + afterEach(() => { + sandbox.restore(); + }); + + describe('initSetPin()', () => { + it('should init values and navigate', () => { + auth.initSetPin(); + expect(store.auth.newPin, 'to equal', ''); + expect(store.auth.pinVerify, 'to equal', ''); + expect(nav.goSetPin, 'was called once'); + }); + }); + + describe('initPin()', () => { + it('should init values and navigate', () => { + auth.initPin(); + expect(store.auth.pin, 'to equal', ''); + expect(nav.goPin, 'was called once'); + }); + }); + + describe('pushPinDigit()', () => { + it('should add a digit for empty pin', () => { + auth.pushPinDigit({ digit: '1', param: 'pin' }); + expect(store.auth.pin, 'to equal', '1'); + }); + + it('should add no digit for max length pin', () => { + store.auth.pin = '000000'; + auth.pushPinDigit({ digit: '1', param: 'pin' }); + expect(store.auth.pin, 'to equal', '000000'); + }); + + it('should go to next screen on last digit', () => { + store.auth.newPin = '00000'; + auth.pushPinDigit({ digit: '1', param: 'newPin' }); + expect(store.auth.newPin, 'to equal', '000001'); + expect(nav.goSetPinConfirm, 'was called once'); + }); + + it('should not go to next screen on fifth digit', () => { + store.auth.newPin = '0000'; + auth.pushPinDigit({ digit: '1', param: 'newPin' }); + expect(store.auth.newPin, 'to equal', '00001'); + expect(nav.goSetPinConfirm, 'was not called'); + }); + }); + + describe('popPinDigit()', () => { + it('should remove digit from a pin', () => { + store.auth.pin = '000000'; + auth.popPinDigit({ param: 'pin' }); + expect(store.auth.pin, 'to equal', '00000'); + }); + + it('should not remove a digit from an empty pin', () => { + store.auth.pin = ''; + auth.popPinDigit({ param: 'pin' }); + expect(store.auth.pin, 'to equal', ''); + }); + + it('should go back to SetPassword screen on empty string', () => { + store.auth.pinVerify = ''; + auth.popPinDigit({ param: 'pinVerify' }); + expect(nav.goSetPin, 'was called once'); + }); + }); + + describe('checkNewPin()', () => { + beforeEach(() => { + sandbox.stub(auth, '_generateWalletPassword'); + }); + + it('should work for two same pins', async () => { + store.auth.newPin = '000000'; + store.auth.pinVerify = '000000'; + await auth.checkNewPin(); + expect( + SecureStore.setItemAsync, + 'was called with', + 'DevicePin', + '000000' + ); + expect(auth._generateWalletPassword, 'was called once'); + }); + + it('should display error for too short pins', async () => { + store.auth.newPin = '00000'; + store.auth.pinVerify = '00000'; + await auth.checkNewPin(); + expect(Alert.alert, 'was called once'); + expect(SecureStore.setItemAsync, 'was not called'); + expect(auth._generateWalletPassword, 'was not called'); + }); + + it('should display error for non matching pins', async () => { + store.auth.newPin = '000000'; + store.auth.pinVerify = '000001'; + await auth.checkNewPin(); + expect(Alert.alert, 'was called once'); + expect(SecureStore.setItemAsync, 'was not called'); + expect(auth._generateWalletPassword, 'was not called'); + }); + }); + + describe('checkPin()', () => { + beforeEach(() => { + sandbox.stub(auth, '_unlockWallet'); + }); + + it('should work for two same pins', async () => { + store.auth.pin = '000000'; + SecureStore.getItemAsync.resolves('000000'); + await auth.checkPin(); + expect(auth._unlockWallet, 'was called once'); + }); + + it('should display error for non matching pins', async () => { + store.auth.pin = '000001'; + SecureStore.getItemAsync.resolves('000000'); + await auth.checkPin(); + expect(Alert.alert, 'was called once'); + expect(auth._unlockWallet, 'was not called'); + }); + }); + + describe('tryFingerprint()', () => { + beforeEach(() => { + sandbox.stub(auth, '_unlockWallet'); + }); + + it('should not unlock wallet without hardware support', async () => { + Fingerprint.hasHardwareAsync.resolves(false); + await auth.tryFingerprint(); + expect(auth._unlockWallet, 'was not called'); + }); + + it('should not unlock wallet if authentication failed', async () => { + Fingerprint.hasHardwareAsync.resolves(true); + Fingerprint.authenticateAsync.resolves({ success: false }); + await auth.tryFingerprint(); + expect(auth._unlockWallet, 'was not called'); + }); + + it('should unlock wallet if authentication worked', async () => { + Fingerprint.hasHardwareAsync.resolves(true); + Fingerprint.authenticateAsync.resolves({ success: true }); + await auth.tryFingerprint(); + expect(auth._unlockWallet, 'was called once'); + }); + }); + + describe('_generateWalletPassword()', () => { + it('should generate a password and store it', async () => { + await auth._generateWalletPassword(); + expect( + SecureStore.setItemAsync, + 'was called with', + 'WalletPassword', + /^[0-9a-f]{64}$/ + ); + expect(store.wallet.newPassword, 'to match', /^[0-9a-f]{64}$/); + expect(store.wallet.passwordVerify, 'to match', /^[0-9a-f]{64}$/); + expect(wallet.checkNewPassword, 'was called once'); + }); + }); + + describe('_unlockWallet()', () => { + it('should not unlock wallet without hardware support', async () => { + SecureStore.getItemAsync.resolves('some-password'); + await auth._unlockWallet(); + expect(SecureStore.getItemAsync, 'was called with', 'WalletPassword'); + expect(store.wallet.password, 'to equal', 'some-password'); + expect(wallet.checkPassword, 'was called once'); + }); + }); + + describe('_totallyNotSecureRandomPassword()', () => { + it('should generate hex encoded 256bit entropy password', async () => { + const pass = auth._totallyNotSecureRandomPassword(); + expect(pass.length, 'to equal', 64); + }); + }); +}); diff --git a/test/unit/action/wallet.spec.js b/test/unit/action/wallet.spec.js index 2d0ef0721..2b0defeec 100644 --- a/test/unit/action/wallet.spec.js +++ b/test/unit/action/wallet.spec.js @@ -113,53 +113,6 @@ describe('Action Wallet Unit Tests', () => { }); }); - describe('pushPinDigit()', () => { - it('should add a digit for empty password', () => { - wallet.pushPinDigit({ digit: '1', param: 'password' }); - expect(store.wallet.password, 'to equal', '1'); - }); - - it('should add no digit for max length password', () => { - store.wallet.password = '000000'; - wallet.pushPinDigit({ digit: '1', param: 'password' }); - expect(store.wallet.password, 'to equal', '000000'); - }); - - it('should go to next screen on last digit', () => { - store.wallet.newPassword = '00000'; - wallet.pushPinDigit({ digit: '1', param: 'newPassword' }); - expect(store.wallet.newPassword, 'to equal', '000001'); - expect(nav.goSetPasswordConfirm, 'was called once'); - }); - - it('should not go to next screen on fifth digit', () => { - store.wallet.newPassword = '0000'; - wallet.pushPinDigit({ digit: '1', param: 'newPassword' }); - expect(store.wallet.newPassword, 'to equal', '00001'); - expect(nav.goSetPasswordConfirm, 'was not called'); - }); - }); - - describe('popPinDigit()', () => { - it('should remove digit from a password', () => { - store.wallet.password = '000000'; - wallet.popPinDigit({ param: 'password' }); - expect(store.wallet.password, 'to equal', '00000'); - }); - - it('should not remove a digit from an empty password', () => { - store.wallet.password = ''; - wallet.popPinDigit({ param: 'password' }); - expect(store.wallet.password, 'to equal', ''); - }); - - it('should go back to SetPassword screen on empty string', () => { - store.wallet.passwordVerify = ''; - wallet.popPinDigit({ param: 'passwordVerify' }); - expect(nav.goSetPassword, 'was called once'); - }); - }); - describe('setRestoringWallet()', () => { it('should clear attributes', () => { wallet.setRestoringWallet({ restoring: true }); @@ -287,16 +240,6 @@ describe('Action Wallet Unit Tests', () => { expect(wallet.initSetPassword, 'was called once'); expect(notification.display, 'was called once'); }); - - it('should support custom length', async () => { - wallet.setNewPassword({ password: 'secret' }); - wallet.setPasswordVerify({ password: 'secret' }); - await wallet.checkNewPassword(6); - expect(wallet.initWallet, 'was called with', { - walletPassword: 'secret', - seedMnemonic: ['foo', 'bar', 'baz'], - }); - }); }); describe('checkResetPassword()', () => {