diff --git a/desktop/desktop.cabal b/desktop/desktop.cabal index 1a5883f06..d0bf508c5 100644 --- a/desktop/desktop.cabal +++ b/desktop/desktop.cabal @@ -60,9 +60,7 @@ library exposed-modules: Desktop.Crypto.BIP , Desktop.Frontend - , Desktop.ImportExport , Desktop.Orphans - , Desktop.Setup , Desktop.Storage.File , Desktop.Syslog , Desktop.WalletApi diff --git a/desktop/src/Desktop/Crypto/BIP.hs b/desktop/src/Desktop/Crypto/BIP.hs index 4b431ef70..477e52353 100644 --- a/desktop/src/Desktop/Crypto/BIP.hs +++ b/desktop/src/Desktop/Crypto/BIP.hs @@ -72,9 +72,6 @@ data BIPStorage a where BIPStorage_RootKey :: BIPStorage Crypto.XPrv deriving instance Show (BIPStorage a) -bipMetaPrefix :: StoreKeyMetaPrefix -bipMetaPrefix = StoreKeyMetaPrefix "BIPStorage_Meta" - -- | Check the validity of the password by signing and verifying a message passwordRoundTripTest :: Crypto.XPrv -> Password -> Bool passwordRoundTripTest xprv (Password pass) = diff --git a/desktop/src/Desktop/Frontend.hs b/desktop/src/Desktop/Frontend.hs index 391507750..d48f07d30 100644 --- a/desktop/src/Desktop/Frontend.hs +++ b/desktop/src/Desktop/Frontend.hs @@ -24,12 +24,13 @@ import Control.Monad ((<=<), guard, void, when) import Control.Monad.Fix (MonadFix) import Control.Monad.Trans (lift) import Control.Monad.IO.Class -import Data.Bifunctor (first, second) +import Data.Bifunctor (second) import Data.Dependent.Sum import Data.Functor.Compose import Data.Functor.Identity import Data.GADT.Compare.TH import Data.Maybe (isJust) +import Data.Proxy (Proxy(..)) import Data.Text (Text) import Data.Time (NominalDiffTime, getCurrentTime, addUTCTime) import Data.Traversable (for) @@ -53,9 +54,9 @@ import Common.Route import Common.Wallet import Frontend.AppCfg import Desktop.Crypto.BIP +import Desktop.Orphans () import Frontend.ModuleExplorer.Impl (loadEditorFromLocalStorage) import Frontend.Log (defaultLogger) -import Frontend.Wallet (genZeroKeyPrefix, _unPublicKeyPrefix) import Frontend.Storage import Frontend.UI.Modal.Impl (showModalBrutal) import Frontend.UI.Dialogs.LogoutConfirmation (uiIdeLogoutConfirmation) @@ -69,10 +70,10 @@ import Frontend.VersionedStore (StoreFrontend(..)) import Frontend.Storage (runBrowserStorageT) import Frontend.Crypto.Password import Frontend.Setup.Common +import Frontend.Setup.ImportExport import Frontend.Setup.Password +import Frontend.Setup.Setup import Frontend.Setup.Widgets -import Desktop.Setup -import Desktop.ImportExport import Desktop.Storage.File import Desktop.WalletApi @@ -161,7 +162,11 @@ bipWallet fileFFI signingReq mkAppCfg = do -> WalletExists -> RoutedT t (R FrontendRoute) m (Event t (DSum LockScreen Identity)) runSetup0 mPrv walletExists = do - keyAndPass <- runSetup (liftFileFFI lift fileFFI) (isJust mPrv) walletExists + let pwCheck k p= pure $ passwordRoundTripTest k p + runF k (Password p) = runBIPCryptoT (pure (k, p)) + importWidgetApis = ImportWidgetApis BIPStorage_RootKey pwCheck runF + + keyAndPass <- runSetup (liftFileFFI lift fileFFI) (isJust mPrv) walletExists importWidgetApis performEvent $ flip push keyAndPass $ \case Right (x, Password p, newWallet) -> pure $ Just $ do setItemStorage localStorage BIPStorage_RootKey x @@ -228,35 +233,9 @@ bipWallet fileFFI signingReq mkAppCfg = do -- the new root , _changePassword_updateKeys = ((second Password) <$> updates, changePasswordDesktopAction) } - , _enabledSettings_exportWallet = Just $ ExportWallet - { _exportWallet_requestExport = \ePw -> do - let bOldPw = (\(Identity (_,oldPw)) -> oldPw) <$> current details - runExport oldPw newPw = do - pfx <- genZeroKeyPrefix - doExport txLogger pfx oldPw newPw - - logExport = do - ts <- liftIO getCurrentTime - sender <- genZeroKeyPrefix - liftIO $ _transactionLogger_walletEvent txLogger - WalletEvent_Export - (_unPublicKeyPrefix sender) - ts - - eExport <- performEvent $ runExport - <$> (Password <$> bOldPw) - <@> (Password <$> ePw) - - let (eErrExport, eGoodExport) = fanEither eExport - - eFileDone <- _fileFFI_deliverFile frontendFileFFI eGoodExport - eLogExportDone <- performEvent $ (\r -> r <$ logExport) <$> eFileDone - - pure $ leftmost - [ Left <$> eErrExport - , first ExportWalletError_FileNotWritable <$> eLogExportDone - ] - } + , _enabledSettings_exportWallet = + let details' = fmap (\(k, p) -> (k, Password p)) <$> details + in Just $ mkExportWallet txLogger frontendFileFFI details' (Proxy :: Proxy (BIPStorage Crypto.XPrv)) , _enabledSettings_transactionLog = True } diff --git a/desktop/src/Desktop/Setup.hs b/desktop/src/Desktop/Setup.hs deleted file mode 100644 index 708c58c10..000000000 --- a/desktop/src/Desktop/Setup.hs +++ /dev/null @@ -1,210 +0,0 @@ -{-# LANGUAGE DataKinds #-} -{-# LANGUAGE FlexibleContexts #-} -{-# LANGUAGE GeneralizedNewtypeDeriving #-} -{-# LANGUAGE LambdaCase #-} -{-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE RecursiveDo #-} -{-# LANGUAGE ScopedTypeVariables #-} -{-# LANGUAGE TypeApplications #-} -{-# LANGUAGE TypeFamilies #-} - --- | Wallet setup screens -module Desktop.Setup (runSetup) where - -import Control.Lens ((<>~), (^.), _1, _2, _3) -import Control.Monad (guard) -import Control.Monad.Fix (MonadFix) -import Control.Monad.Trans.Maybe (MaybeT(MaybeT), runMaybeT) -import Data.Foldable (traverse_) -import Data.Maybe (isNothing) -import Language.Javascript.JSaddle (MonadJSM, liftJSM) -import Reflex.Dom.Core -import qualified Cardano.Crypto.Wallet as Crypto -import qualified Data.Text as T -import System.FilePath (takeFileName) - -import Frontend.AppCfg (FileFFI(..), FileType(FileType_Import)) -import Desktop.ImportExport (doImport, ImportWalletError(..)) -import Pact.Server.ApiClient (HasTransactionLogger, askTransactionLogger) -import Frontend.Storage.Class (HasStorage) -import Frontend.UI.Button -import Frontend.UI.Widgets.Helpers (imgWithAlt) -import Frontend.UI.Widgets -import Frontend.Setup.Widgets -import Frontend.Setup.Common -import Frontend.Crypto.Password -import Obelisk.Generated.Static - -runSetup - :: forall t m - . ( DomBuilder t m - , MonadFix m - , MonadHold t m - , PerformEvent t m - , PostBuild t m - , MonadJSM (Performable m) - , TriggerEvent t m - , HasStorage (Performable m) - , MonadSample t (Performable m) - , HasTransactionLogger m - ) - => FileFFI t m - -> Bool - -> WalletExists - -> m (Event t (Either () (Crypto.XPrv, Password, Bool))) -runSetup fileFFI showBackOverride walletExists = setupDiv "fullscreen" $ mdo - let dCurrentScreen = (^._1) <$> dwf - - eBack <- fmap (domEvent Click . fst) $ elDynClass "div" ((setupClass "back " <>) . hideBack <$> dCurrentScreen) $ - el' "span" $ do - elClass "i" "fa fa-fw fa-chevron-left" $ blank - text "Back" - - _ <- dyn_ $ walletSetupRecoverHeader <$> dCurrentScreen - - dwf <- divClass "wrapper" $ - workflow (splashScreenWithImport walletExists fileFFI eBack) - - pure $ leftmost - [ fmap Right $ switchDyn $ (^. _2) <$> dwf - , attachWithMaybe (\s () -> Left () <$ guard (s == WalletScreen_SplashScreen)) (current dCurrentScreen) eBack - , fmap Left $ switchDyn $ (^. _3) <$> dwf - ] - where - hideBack ws = - if not showBackOverride && (ws `elem` [WalletScreen_SplashScreen, WalletScreen_Done]) then - setupClass "hide" - else - setupScreenClass ws - -splashScreenWithImport - :: (DomBuilder t m, MonadFix m, MonadHold t m, PerformEvent t m - , PostBuild t m, MonadJSM (Performable m), TriggerEvent t m, HasStorage (Performable m) - , MonadSample t (Performable m) - , HasTransactionLogger m - ) - => WalletExists - -> FileFFI t m - -> Event t () - -> SetupWF Crypto.XPrv t m -splashScreenWithImport walletExists fileFFI eBack = selfWF - where - selfWF = Workflow $ setupDiv "splash" $ do - agreed <- splashScreenAgreement - let hasAgreed = gate (current agreed) - disabledCfg = uiButtonCfg_disabled .~ fmap not agreed - restoreCfg = uiButtonCfg_class <>~ "setup__restore-existing-button" - - create <- confirmButton (def & disabledCfg ) "Create a new wallet" - - restoreBipPhrase <- uiButtonDyn (btnCfgSecondary & disabledCfg & restoreCfg) - $ text "Restore from recovery phrase" - - restoreImport <- uiButtonDyn (btnCfgSecondary & disabledCfg & restoreCfg) - $ text "Restore from wallet export" - - finishSetupWF WalletScreen_SplashScreen $ leftmost - [ createNewWallet selfWF eBack <$ hasAgreed create - , restoreBipWallet selfWF eBack <$ hasAgreed restoreBipPhrase - , restoreFromImport walletExists fileFFI selfWF eBack <$ hasAgreed restoreImport - ] - -restoreFromImport - :: forall t m - . ( DomBuilder t m, MonadFix m, MonadHold t m, PerformEvent t m - , PostBuild t m, MonadJSM (Performable m), HasStorage (Performable m) - , MonadSample t (Performable m) - , HasTransactionLogger m - ) - => WalletExists - -> FileFFI t m - -> SetupWF Crypto.XPrv t m - -> Event t () - -> SetupWF Crypto.XPrv t m -restoreFromImport walletExists fileFFI backWF eBack = nagScreen - where - nagMsgs = case walletExists of - WalletExists_Yes -> - ("You are about to replace the current wallet's data" - ,"Reminder: Importing a wallet file will replace the data within the current wallet." - ) - WalletExists_No -> - ("Please select the wallet import file." - ,"Reminder: You will need your wallet password to proceed." - ) - - nagBack = case walletExists of - WalletExists_No -> pure never - WalletExists_Yes -> uiButtonDyn - -- TODO: Don't reuse this class or at least rename it - (btnCfgSecondary & uiButtonCfg_class <>~ "setup__restore-existing-button") - (text "Go back and export current wallet") - - nagScreen = Workflow $ setupDiv "splash" $ do - splashLogo - let (nagTitle, nagReminder) = nagMsgs - elClass "h1" "setup__recover-import-title" $ text nagTitle - elClass "p" "setup__recover-import-text" $ text nagReminder - eImport <- confirmButton def "Select Import File" - eExit <- nagBack - pure - ( (WalletScreen_RecoverImport, never, eExit) - , leftmost - [ backWF <$ (eBack <> eExit) - , importScreen <$ eImport - ] - ) - - importScreen = Workflow $ setupDiv "splash" $ mdo - splashLogo - elClass "h1" "setup__recover-import-title" $ text "Import File Password" - elClass "p" "setup__recover-import-text" $ text "Enter the password for the chosen wallet file in order to authorize access to the data." - - let disabled = isNothing <$> dmValidForm - dErr <- holdDyn Nothing (leftmost [Just <$> eImportErr, Nothing <$ updated dmValidForm]) - (eSubmit, (dFileSelected, pwInput)) <- setupForm "" "Import File" disabled $ mdo - ePb <- getPostBuild - (selectElt, _) <- elClass' "div" "setup__recover-import-file" $ do - imgWithAlt (static @"img/import.svg") "Import" blank - divClass "setup__recover-import-file-text" $ dynText $ ffor dFileSelected $ - maybe "Select a file" (T.pack . takeFileName . fst) - - performEvent_ $ liftJSM (_fileFFI_openFileDialog fileFFI FileType_Import) <$ - ((domEvent Click selectElt) <> ePb) - - dFileSelected <- holdDyn Nothing (Just <$> _fileFFI_externalFileOpened fileFFI) - - pw <- uiPassword (setupClass "password-wrapper") (setupClass "password") "Enter import wallet password" - - dyn_ $ ffor dErr $ traverse_ $ \err -> - elClass "p" "error_inline" $ text $ case err of - ImportWalletError_InvalidCommandLogDestination -> "Destination for transaction log file is invalid" - ImportWalletError_CommandLogWriteError -> "Unable to write transaction log file" - ImportWalletError_PasswordIncorrect -> "Incorrect Password" - ImportWalletError_NoRootKey -> "Backup cannot be restored as it does not contain a BIP Root Key" - ImportWalletError_NotJson eMsg -> "Backup cannot be restored as it is not a valid json file. Error: " <> eMsg - ImportWalletError_DecodeError section ver eMsg -> - "Backup section " <> section <> " cannot be parsed as version " <> tshow ver <> " with error: " <> eMsg - ImportWalletError_UnknownVersion section ver -> - "Backup section " <> section <> " has an unknown version " <> tshow ver <> ". It's likely that this backup is from a newer version of chainweaver." - - - pure (dFileSelected, pw) - - eExit <- nagBack - let dmValidForm = runMaybeT $ (,) - <$> MaybeT (nonEmptyPassword <$> (_inputElement_value pwInput)) - <*> MaybeT (fmap snd <$> dFileSelected) - - txLogger <- askTransactionLogger - eImport <- performEvent $ tagMaybe (fmap (uncurry (doImport @t txLogger)) <$> current dmValidForm) eSubmit - - let (eImportErr, eImportDone) = fanEither eImport - - pure - ( (WalletScreen_RecoverImport, (\(prv,pw) -> (prv, pw, False)) <$> eImportDone, eExit) - , backWF <$ (eBack <> eExit) - ) - - nonEmptyPassword "" = Nothing - nonEmptyPassword pw = Just (Password pw) diff --git a/frontend/frontend.cabal b/frontend/frontend.cabal index e886e4f21..661eea386 100644 --- a/frontend/frontend.cabal +++ b/frontend/frontend.cabal @@ -147,7 +147,9 @@ library , Frontend.Routes , Frontend.Setup.Browser , Frontend.Setup.Common + , Frontend.Setup.ImportExport , Frontend.Setup.Password + , Frontend.Setup.Setup , Frontend.Setup.Widgets , Frontend.Storage , Frontend.Storage.Class diff --git a/frontend/src/Frontend.hs b/frontend/src/Frontend.hs index f63a64252..b789a5524 100644 --- a/frontend/src/Frontend.hs +++ b/frontend/src/Frontend.hs @@ -5,19 +5,34 @@ {-# LANGUAGE TupleSections #-} {-# LANGUAGE TypeApplications #-} {-# LANGUAGE RecursiveDo #-} +{-# LANGUAGE PackageImports #-} module Frontend where +import Control.Lens ((^.)) import Control.Monad (join, void) +import Control.Monad.Catch import Control.Monad.IO.Class +import Data.Coerce (coerce) import Data.Maybe (listToMaybe) import Data.Text (Text) import qualified Data.Text as T +import qualified Data.Text.Encoding as T +import qualified GHCJS.DOM as DOM +import qualified GHCJS.DOM.Blob as Blob +import qualified "ghcjs-dom" GHCJS.DOM.Document as Document import qualified GHCJS.DOM.EventM as EventM import qualified GHCJS.DOM.FileReader as FileReader +import qualified GHCJS.DOM.HTMLAnchorElement as HTMLAnchorElement +import qualified GHCJS.DOM.HTMLBaseElement as HTMLBaseElement import qualified GHCJS.DOM.HTMLElement as HTMLElement import qualified GHCJS.DOM.HTMLInputElement as HTMLInput import qualified GHCJS.DOM.Types as Types import qualified GHCJS.DOM.File as JSFile +import qualified GHCJS.DOM.Node as Node +import qualified GHCJS.DOM.Types as DOM +import qualified GHCJS.DOM.URL as URL +import Foreign.JavaScript.Utils (bsToArrayBuffer) +import Language.Javascript.JSaddle (JSException(..), js0, js1, (<#), (!), valToText) import Reflex.Dom import Pact.Server.ApiClient (runTransactionLoggerT, noLogger) import Obelisk.Frontend @@ -72,7 +87,7 @@ frontend = Frontend let fileFFI = FileFFI { _fileFFI_externalFileOpened = fileOpened , _fileFFI_openFileDialog = liftJSM . triggerOpen - , _fileFFI_deliverFile = \_ -> pure never + , _fileFFI_deliverFile = triggerFileDownload } printResponsesHandler = pure $ FRPHandler never $ performEvent . fmap (liftIO . print) bipWalletBrowser fileFFI $ \enabledSettings -> AppCfg @@ -116,6 +131,24 @@ openFileDialog = do HTMLElement.click $ _inputElement_raw input pure (fmapMaybe id mContents, open) +triggerFileDownload :: (MonadJSM (Performable m), PerformEvent t m) + => Event t (FilePath, Text) -> m (Event t (Either Text FilePath)) +triggerFileDownload ev = performEvent $ ffor ev $ \(fileName, c) -> liftJSM $ catch (do + doc <- DOM.currentDocumentUnchecked + a :: HTMLAnchorElement.HTMLAnchorElement <- coerce <$> Document.createElement doc ("a" :: Text) + array <- bsToArrayBuffer (T.encodeUtf8 c) + blob <- Blob.newBlob [array] (Nothing :: Maybe DOM.BlobPropertyBag) + (url :: DOM.JSString) <- URL.createObjectURL blob + HTMLBaseElement.setHref (coerce a) url + HTMLAnchorElement.setDownload a fileName + body <- Document.getBodyUnchecked doc + void $ Node.appendChild body a + HTMLElement.click a + void $ Node.removeChild body a + URL.revokeObjectURL url + pure (Right fileName)) + (\(JSException e) -> valToText e >>= return . Left) + loaderMarkup :: DomBuilder t m => m () loaderMarkup = divClass "spinner" $ do divClass "spinner__cubes" $ do diff --git a/frontend/src/Frontend/Setup/Browser.hs b/frontend/src/Frontend/Setup/Browser.hs index b74245fae..75b171ae4 100644 --- a/frontend/src/Frontend/Setup/Browser.hs +++ b/frontend/src/Frontend/Setup/Browser.hs @@ -12,7 +12,6 @@ -- | Wallet setup screens module Frontend.Setup.Browser (runSetup, bipWalletBrowser) where -import Control.Lens ((<>~), (^.), _1, _2, _3) import Control.Monad (guard) import Control.Monad.Fix (MonadFix) import Control.Monad.Trans (lift) @@ -26,13 +25,13 @@ import Data.Functor.Compose import Data.Functor.Identity import Data.GADT.Compare.TH import Data.GADT.Show.TH +import Data.Proxy (Proxy(..)) import Data.Traversable (for) import Data.Universe.Some.TH import Language.Javascript.JSaddle (MonadJSM) import Reflex.Dom.Core hiding (Key) -import Pact.Server.ApiClient (HasTransactionLogger, askTransactionLogger, _transactionLogger_rotateLogFile) +import Pact.Server.ApiClient (HasTransactionLogger, TransactionLogger, askTransactionLogger, _transactionLogger_rotateLogFile) import Obelisk.Route.Frontend -import Obelisk.Generated.Static import Common.Wallet import Common.Route import qualified Frontend.App as App (app) @@ -43,7 +42,9 @@ import Frontend.Crypto.Ed25519 import Frontend.Crypto.Browser import Frontend.Foundation import Frontend.Setup.Common +import Frontend.Setup.ImportExport import Frontend.Setup.Password +import Frontend.Setup.Setup import Frontend.Setup.Widgets import Frontend.Storage import Frontend.UI.Button @@ -77,72 +78,6 @@ type MkAppCfg t m -- ^ Settings -> AppCfg PrivateKey t (RoutedT t (R FrontendRoute) (BrowserCryptoT t m)) -runSetup - :: ( DomBuilder t m - , MonadFix m - , MonadHold t m - , PerformEvent t m - , PostBuild t m - , TriggerEvent t m - , MonadJSM (Performable m) - , HasStorage (Performable m) - , MonadSample t (Performable m) - , DerivableKey key mnemonic - ) - => FileFFI t m - -> Bool - -> WalletExists - -> m (Event t (Either () (key, Password, Bool))) -runSetup fileFFI showBackOverride walletExists = setupDiv "fullscreen" $ mdo - let dCurrentScreen = (^._1) <$> dwf - - eBack <- fmap (domEvent Click . fst) $ elDynClass "div" ((setupClass "back " <>) . hideBack <$> dCurrentScreen) $ - el' "span" $ do - elClass "i" "fa fa-fw fa-chevron-left" $ blank - text "Back" - - _ <- dyn_ $ walletSetupRecoverHeader <$> dCurrentScreen - - dwf <- divClass "wrapper" $ - workflow (splashScreenBrowser eBack) - - pure $ leftmost - [ fmap Right $ switchDyn $ (^. _2) <$> dwf - , attachWithMaybe (\s () -> Left () <$ guard (s == WalletScreen_SplashScreen)) (current dCurrentScreen) eBack - , fmap Left $ switchDyn $ (^. _3) <$> dwf - ] - where - hideBack ws = - if not showBackOverride && (ws `elem` [WalletScreen_SplashScreen, WalletScreen_Done]) then - setupClass "hide" - else - setupScreenClass ws - -splashScreenBrowser - :: (DomBuilder t m, MonadFix m, MonadHold t m, PerformEvent t m - , PostBuild t m, MonadJSM (Performable m), TriggerEvent t m, HasStorage (Performable m) - , MonadSample t (Performable m), DerivableKey key mnemonic - ) - => Event t () - -> SetupWF key t m -splashScreenBrowser eBack = selfWF - where - selfWF = Workflow $ setupDiv "splash" $ do - agreed <- splashScreenAgreement - let hasAgreed = gate (current agreed) - disabledCfg = uiButtonCfg_disabled .~ fmap not agreed - restoreCfg = uiButtonCfg_class <>~ "setup__restore-existing-button" - - create <- confirmButton (def & disabledCfg ) "Create a new wallet" - - restoreBipPhrase <- uiButtonDyn (btnCfgSecondary & disabledCfg & restoreCfg) - $ text "Restore from recovery phrase" - - finishSetupWF WalletScreen_SplashScreen $ leftmost - [ createNewWallet selfWF eBack <$ hasAgreed create - , restoreBipWallet selfWF eBack <$ hasAgreed restoreBipPhrase - ] - bipWalletBrowser :: forall js t m . ( MonadWidget t m @@ -172,7 +107,9 @@ bipWalletBrowser fileFFI mkAppCfg = do -> WalletExists -> RoutedT t (R FrontendRoute) m (Event t (DSum LockScreen Identity)) runSetup0 mPrv walletExists = do - keyAndPass <- runSetup (liftFileFFI lift fileFFI) (isJust mPrv) walletExists + let runF k p = runBrowserCryptoT (pure (k, p)) + importWidgetApis = ImportWidgetApis BIPStorage_RootKey passwordRoundTripTest runF + keyAndPass <- runSetup (liftFileFFI lift fileFFI) (isJust mPrv) walletExists importWidgetApis performEvent $ flip push keyAndPass $ \case Right (x, Password p, newWallet) -> pure $ Just $ do setItemStorage localStorage BIPStorage_RootKey x @@ -214,7 +151,7 @@ bipWalletBrowser fileFFI mkAppCfg = do (updates, trigger) <- newTriggerEvent let frontendFileFFI = liftFileFFI (lift . lift) fileFFI App.app sidebarLogoutLink frontendFileFFI $ mkAppCfg $ - appSettingsBrowser trigger details updates changePasswordBrowserAction + appSettingsBrowser txLogger frontendFileFFI trigger details updates changePasswordBrowserAction setRoute $ landingPageRoute <$ onLogoutConfirm pure $ leftmost @@ -228,20 +165,23 @@ appSettingsBrowser :: , HasStorage (Performable m) , PerformEvent t m , MonadJSM (Performable m) + , HasCrypto PrivateKey (Performable m) ) - => ((PrivateKey, Password) -> IO ()) + => TransactionLogger + -> FileFFI t m + -> ((PrivateKey, Password) -> IO ()) -> Dynamic t (Identity (PrivateKey, Password)) -> Event t (PrivateKey, Password) -> (Int -> PrivateKey -> Password -> (Performable m) (Key PrivateKey)) -> EnabledSettings PrivateKey t m -appSettingsBrowser newPwdTrigger details keyUpdates changePasswordBrowserAction = EnabledSettings +appSettingsBrowser txLogger frontendFileFFI newPwdTrigger details keyUpdates changePasswordBrowserAction = EnabledSettings { _enabledSettings_changePassword = Just $ ChangePassword { _changePassword_requestChange = performEvent . attachWith doChange (current details) -- When updating the keys here, we just always regenerate the key from -- the new root , _changePassword_updateKeys = (keyUpdates, changePasswordBrowserAction) } - , _enabledSettings_exportWallet = Nothing + , _enabledSettings_exportWallet = Just $ mkExportWallet txLogger frontendFileFFI details (Proxy :: Proxy (BIPStorage PrivateKey)) , _enabledSettings_transactionLog = False } where diff --git a/frontend/src/Frontend/Setup/Common.hs b/frontend/src/Frontend/Setup/Common.hs index 327ff1896..5d91d3c1a 100644 --- a/frontend/src/Frontend/Setup/Common.hs +++ b/frontend/src/Frontend/Setup/Common.hs @@ -16,14 +16,28 @@ import Control.Error (hush) import Control.Monad (unless, void) import Control.Monad.Fix (MonadFix) import Control.Monad.IO.Class +import Control.Monad.Trans.Maybe (MaybeT(MaybeT), runMaybeT) +import Data.Aeson (FromJSON, ToJSON) +import Data.Bifunctor (first) import Data.Bool (bool) -import Data.Foldable (fold) +import Data.Constraint.Extras (Has) +import Data.Dependent.Map (DMap) +import Data.Foldable (fold, traverse_) +import Data.Functor.Identity (Identity(..)) +import Data.GADT.Compare (GCompare) +import Data.GADT.Show (GShow) import Data.Maybe (isNothing, isJust) +import Data.Proxy (Proxy(..)) import Data.Text (Text) +import Data.Time (getCurrentTime) +import Data.Universe.Some (UniverseSome) import Language.Javascript.JSaddle (MonadJSM, liftJSM) import Reflex.Dom.Core import qualified Data.Map as Map import qualified Data.Text as T +import System.FilePath (takeFileName) + +import Pact.Server.ApiClient (HasTransactionLogger, TransactionLogger(..), WalletEvent(..), askTransactionLogger) import Frontend.UI.Button import Frontend.UI.Dialogs.ChangePassword (minPasswordLength) @@ -31,10 +45,14 @@ import Frontend.UI.Widgets.Helpers (imgWithAlt) import Frontend.UI.Widgets import Obelisk.Generated.Static +import Frontend.AppCfg (FileFFI(..), FileType(FileType_Import), ExportWallet(..), ExportWalletError(..)) +import Frontend.Setup.ImportExport import Frontend.Setup.Password import Frontend.Setup.Widgets +import Frontend.Storage.Class import Frontend.Crypto.Class import Frontend.Crypto.Password +import Frontend.Wallet (genZeroKeyPrefix, _unPublicKeyPrefix) -- | Used for changing the settings in the passphrase widget. data PassphraseStage @@ -470,6 +488,108 @@ restoreBipWallet backWF eBack = Workflow $ do , backWF <$ eBack ) +restoreFromImport + :: forall t m n key bipStorage + . ( DomBuilder t m, MonadFix m, MonadHold t m, PerformEvent t m + , PostBuild t m, MonadJSM (Performable m), HasStorage (Performable m) + , MonadSample t (Performable m) + , HasTransactionLogger m + , ImportWidgetConstraints bipStorage key n (Performable m) + ) + => WalletExists + -> FileFFI t m + -> ImportWidgetApis bipStorage key n (Performable m) + -> SetupWF key t m + -> Event t () + -> SetupWF key t m +restoreFromImport walletExists fileFFI importWidgetApis backWF eBack = nagScreen + where + nagMsgs = case walletExists of + WalletExists_Yes -> + ("You are about to replace the current wallet's data" + ,"Reminder: Importing a wallet file will replace the data within the current wallet." + ) + WalletExists_No -> + ("Please select the wallet import file." + ,"Reminder: You will need your wallet password to proceed." + ) + + nagBack = case walletExists of + WalletExists_No -> pure never + WalletExists_Yes -> uiButtonDyn + -- TODO: Don't reuse this class or at least rename it + (btnCfgSecondary & uiButtonCfg_class <>~ "setup__restore-existing-button") + (text "Go back and export current wallet") + + nagScreen = Workflow $ setupDiv "splash" $ do + splashLogo + let (nagTitle, nagReminder) = nagMsgs + elClass "h1" "setup__recover-import-title" $ text nagTitle + elClass "p" "setup__recover-import-text" $ text nagReminder + eImport <- confirmButton def "Select Import File" + eExit <- nagBack + pure + ( (WalletScreen_RecoverImport, never, eExit) + , leftmost + [ backWF <$ (eBack <> eExit) + , importScreen <$ eImport + ] + ) + + importScreen = Workflow $ setupDiv "splash" $ mdo + splashLogo + elClass "h1" "setup__recover-import-title" $ text "Import File Password" + elClass "p" "setup__recover-import-text" $ text "Enter the password for the chosen wallet file in order to authorize access to the data." + + let disabled = isNothing <$> dmValidForm + dErr <- holdDyn Nothing (leftmost [Just <$> eImportErr, Nothing <$ updated dmValidForm]) + (eSubmit, (dFileSelected, pwInput)) <- setupForm "" "Import File" disabled $ mdo + ePb <- getPostBuild + (selectElt, _) <- elClass' "div" "setup__recover-import-file" $ do + imgWithAlt (static @"img/import.svg") "Import" blank + divClass "setup__recover-import-file-text" $ dynText $ ffor dFileSelected $ + maybe "Select a file" (T.pack . takeFileName . fst) + + performEvent_ $ liftJSM (_fileFFI_openFileDialog fileFFI FileType_Import) <$ + ((domEvent Click selectElt) <> ePb) + + dFileSelected <- holdDyn Nothing (Just <$> _fileFFI_externalFileOpened fileFFI) + + pw <- uiPassword (setupClass "password-wrapper") (setupClass "password") "Enter import wallet password" + + dyn_ $ ffor dErr $ traverse_ $ \err -> + elClass "p" "error_inline" $ text $ case err of + ImportWalletError_InvalidCommandLogDestination -> "Destination for transaction log file is invalid" + ImportWalletError_CommandLogWriteError -> "Unable to write transaction log file" + ImportWalletError_PasswordIncorrect -> "Incorrect Password" + ImportWalletError_NoRootKey -> "Backup cannot be restored as it does not contain a BIP Root Key" + ImportWalletError_NotJson eMsg -> "Backup cannot be restored as it is not a valid json file. Error: " <> eMsg + ImportWalletError_DecodeError section ver eMsg -> + "Backup section " <> section <> " cannot be parsed as version " <> tshow ver <> " with error: " <> eMsg + ImportWalletError_UnknownVersion section ver -> + "Backup section " <> section <> " has an unknown version " <> tshow ver <> ". It's likely that this backup is from a newer version of chainweaver." + + + pure (dFileSelected, pw) + + eExit <- nagBack + let dmValidForm = runMaybeT $ (,) + <$> MaybeT (nonEmptyPassword <$> (_inputElement_value pwInput)) + <*> MaybeT (fmap snd <$> dFileSelected) + + txLogger <- askTransactionLogger + eImport <- performEvent $ tagMaybe (fmap (uncurry (doImport @t txLogger importWidgetApis)) <$> current dmValidForm) eSubmit + + let (eImportErr, eImportDone) = fanEither eImport + + pure + ( (WalletScreen_RecoverImport, (\(prv,pw) -> (prv, pw, False)) <$> eImportDone, eExit) + , backWF <$ (eBack <> eExit) + ) + + nonEmptyPassword "" = Nothing + nonEmptyPassword pw = Just (Password pw) + mkSidebarLogoutLink :: (TriggerEvent t m, PerformEvent t n, PostBuild t n, DomBuilder t n, MonadIO (Performable n)) => m (Event t (), n ()) mkSidebarLogoutLink = do (logout, triggerLogout) <- newTriggerEvent @@ -477,3 +597,49 @@ mkSidebarLogoutLink = do clk <- uiSidebarIcon (pure False) (static @"img/menu/logout.svg") "Logout" performEvent_ $ liftIO . triggerLogout <$> clk +mkExportWallet :: + ( PerformEvent t m + , HasCrypto key (Performable m) + , MonadJSM (Performable m) + , HasStorage (Performable m) + , FromJSON key + , ToJSON key + , ToJSON (DMap bipStorage Identity) + , Has FromJSON bipStorage + , GCompare bipStorage + , UniverseSome bipStorage + , GShow bipStorage + ) + => TransactionLogger + -> FileFFI t m + -> Dynamic t (Identity (key, Password)) + -> Proxy (bipStorage key) + -> ExportWallet t m +mkExportWallet txLogger frontendFileFFI details proxy = ExportWallet + { _exportWallet_requestExport = \ePw -> do + let bOldPw = (\(Identity (_,oldPw)) -> oldPw) <$> current details + runExport oldPw newPw = do + pfx <- genZeroKeyPrefix + doExport txLogger pfx oldPw newPw proxy + + logExport = do + ts <- liftIO getCurrentTime + sender <- genZeroKeyPrefix + liftIO $ _transactionLogger_walletEvent txLogger + WalletEvent_Export + (_unPublicKeyPrefix sender) + ts + + eExport <- performEvent $ runExport + <$> bOldPw <@> (Password <$> ePw) + + let (eErrExport, eGoodExport) = fanEither eExport + + eFileDone <- _fileFFI_deliverFile frontendFileFFI eGoodExport + eLogExportDone <- performEvent $ (\r -> r <$ logExport) <$> eFileDone + + pure $ leftmost + [ Left <$> eErrExport + , first ExportWalletError_FileNotWritable <$> eLogExportDone + ] + } diff --git a/desktop/src/Desktop/ImportExport.hs b/frontend/src/Frontend/Setup/ImportExport.hs similarity index 72% rename from desktop/src/Desktop/ImportExport.hs rename to frontend/src/Frontend/Setup/ImportExport.hs index 3a0e06a39..4f346f551 100644 --- a/desktop/src/Desktop/ImportExport.hs +++ b/frontend/src/Frontend/Setup/ImportExport.hs @@ -2,37 +2,40 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE ScopedTypeVariables #-} {-# LANGUAGE TypeApplications #-} -module Desktop.ImportExport where +{-# LANGUAGE Rank2Types #-} +{-# LANGUAGE ConstraintKinds #-} +module Frontend.Setup.ImportExport where -import qualified Cardano.Crypto.Wallet as Crypto import Control.Lens (over, mapped, _Left) import Control.Error (hoistEither, failWith) import Control.Monad (unless) import Control.Monad.IO.Class (MonadIO, liftIO) import Control.Monad.Except (ExceptT(ExceptT), runExceptT, throwError) import Control.Monad.Trans (lift) -import Data.Aeson (FromJSON, Value, eitherDecode, object, (.=), (.:), withObject) +import Data.Aeson (FromJSON, Value, eitherDecode, object, (.=), (.:), withObject, ToJSON) import Data.Aeson.Types (Parser, parseEither) import Data.Aeson.Text (encodeToLazyText) import Data.Bifunctor (first) +import Data.Constraint.Extras (Has) import Data.Foldable (traverse_) import Data.List (intercalate) +import Data.Proxy (Proxy) import Data.Dependent.Map (DMap) +import Data.GADT.Compare (GCompare) +import Data.GADT.Show (GShow) import qualified Data.Dependent.Map as DMap import Data.Time (getZonedTime, zonedTimeToLocalTime, iso8601DateFormat, formatTime) import Data.Functor.Identity (Identity, runIdentity) import Language.Javascript.JSaddle (MonadJSM) -import Data.Time (getCurrentTime) -import System.Locale.Read (getCurrentLocale) +import Data.Time (getCurrentTime, defaultTimeLocale) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.Lazy as TL import qualified Data.Text.Lazy.IO as TL import qualified Data.Text.Lazy.Encoding as TL +import Data.Universe.Some (UniverseSome) import Reflex -import Desktop.Orphans () -import Desktop.Crypto.BIP (BIPStorage(..), bipMetaPrefix, runBIPCryptoT, passwordRoundTripTest) import Frontend.AppCfg (ExportWalletError(..), FileType(FileType_Import), fileTypeExtension) import Pact.Server.ApiClient (WalletEvent (..), TransactionLogger (..)) import Frontend.Crypto.Class (HasCrypto) @@ -40,6 +43,7 @@ import Frontend.Wallet (PublicKeyPrefix (..), genZeroKeyPrefix) import Frontend.Storage (HasStorage, dumpLocalStorage) import Frontend.VersionedStore (VersionedStorage(..), StorageVersion, VersioningDecodeJsonError(..)) import qualified Frontend.VersionedStore as FrontendStore +import Frontend.Storage (StoreKeyMetaPrefix(..)) import Frontend.Crypto.Password data ImportWalletError @@ -67,6 +71,9 @@ storeFrontendDataKey = "StoreFrontend_Data" chainweaverImportObj :: String chainweaverImportObj = "ChainweaverImport" +bipMetaPrefix :: StoreKeyMetaPrefix +bipMetaPrefix = StoreKeyMetaPrefix "BIPStorage_Meta" + hoistParser :: Monad m => Text @@ -100,36 +107,50 @@ extractImportVersionField extractImportVersionField = extractImportDataField +data ImportWidgetApis bipStorage key n m = ImportWidgetApis + { _importWidgetApis_bipStorageKey :: bipStorage key + , _importWidgetApis_pwCheck :: (key -> Password -> m Bool) + , _importWidgetApis_runF :: (forall a . () => key -> Password -> n m a -> m a) + } + +type ImportWidgetConstraints bipStorage key n m = + ( MonadIO m, MonadIO (n m), HasCrypto key (n m), MonadIO (n m), + HasStorage (n m), FromJSON key, ToJSON key, FromJSON (DMap bipStorage Identity), + GCompare bipStorage + ) + doImport - :: forall t m + :: forall t m n key bipStorage . ( MonadIO m , MonadJSM m , HasStorage m , MonadSample t m , Reflex t + , ImportWidgetConstraints bipStorage key n m ) => TransactionLogger + -> ImportWidgetApis bipStorage key n m -> Password -> Text -- Backup data - -> m (Either ImportWalletError (Crypto.XPrv, Password)) -doImport txLogger pw contents = runExceptT $ do + -> m (Either ImportWalletError (key, Password)) +doImport txLogger (ImportWidgetApis bipStorageKey passwordRoundTripTest runF) pw contents = runExceptT $ do jVal <- hoistEither . first (ImportWalletError_NotJson . T.pack) $ eitherDecode @Value (TL.encodeUtf8 . TL.fromStrict $ contents) bVer <- extractImportVersionField bipStorageVersionKey 0 jVal unless (bVer == 0) $ throwError $ ImportWalletError_UnknownVersion "BIPStorage" bVer - bipCrypto <- extractImportDataField @(DMap BIPStorage Identity) bipStorageDataKey 0 jVal - rootKey <- failWith ImportWalletError_NoRootKey (runIdentity <$> DMap.lookup BIPStorage_RootKey bipCrypto) + bipCrypto <- extractImportDataField @(DMap bipStorage Identity) bipStorageDataKey 0 jVal + rootKey <- failWith ImportWalletError_NoRootKey (runIdentity <$> DMap.lookup bipStorageKey bipCrypto) - let pwOk = passwordRoundTripTest rootKey pw + pwOk <- lift $ passwordRoundTripTest rootKey pw unless pwOk $ throwError ImportWalletError_PasswordIncorrect feVer <- extractImportVersionField storeFrontendVersionKey 0 jVal feData <- extractImportDataField @Value storeFrontendDataKey feVer jVal - _ <- ExceptT $ runBIPCryptoT (constant (rootKey, unPassword pw)) $ do - let vStore = FrontendStore.versionedStorage + _ <- ExceptT $ runF rootKey pw $ do + let vStore = FrontendStore.versionedStorage :: VersionedStorage (n m) (FrontendStore.StoreFrontend key) feLatestEither <- first (expandDecodeVersionJsonError storeFrontendDataKey feVer) <$> (_versionedStorage_decodeVersionedJson vStore feVer feData) @@ -151,35 +172,43 @@ doImport txLogger pw contents = runExceptT $ do ImportWalletError_UnknownVersion section ver doExport - :: forall m - . ( HasCrypto Crypto.XPrv m + :: forall m key bipStorage + . ( HasCrypto key m , MonadJSM m , HasStorage m + , FromJSON key + , ToJSON key + , HasCrypto key m + , ToJSON (DMap bipStorage Identity) + , Has FromJSON bipStorage + , GCompare bipStorage + , UniverseSome bipStorage + , GShow bipStorage ) => TransactionLogger -> PublicKeyPrefix -> Password -> Password + -> Proxy (bipStorage key) -> m (Either ExportWalletError (FilePath, Text)) -doExport txLogger keyPfx oldPw pw = runExceptT $ do +doExport txLogger keyPfx oldPw pw _ = runExceptT $ do unless (oldPw == pw) $ throwError ExportWalletError_PasswordIncorrect - let store = FrontendStore.versionedStorage @Crypto.XPrv @m + let store = FrontendStore.versionedStorage @key @m -- Trigger an upgrade of the storage to ensure we're exporting the latest version. _ <- ExceptT $ over (mapped . _Left) (const ExportWalletError_UpgradeFailed) $ _versionedStorage_upgradeStorage store txLogger - (bipVer,bipData) <- lift $ dumpLocalStorage @BIPStorage bipMetaPrefix + (bipVer,bipData) <- lift $ dumpLocalStorage @bipStorage bipMetaPrefix (feVer, feData) <- lift $ _versionedStorage_dumpLocalStorage store - tl <- liftIO getCurrentLocale lt <- zonedTimeToLocalTime <$> liftIO getZonedTime pure $ ( intercalate "." [ T.unpack $ _unPublicKeyPrefix keyPfx -- Mac does something weird with colons in the name and converts them to subdirs... - , formatTime tl (iso8601DateFormat (Just "%H-%M-%S")) lt + , formatTime defaultTimeLocale (iso8601DateFormat (Just "%H-%M-%S")) lt , T.unpack (fileTypeExtension FileType_Import) ] , TL.toStrict $ encodeToLazyText $ object $ diff --git a/frontend/src/Frontend/Setup/Setup.hs b/frontend/src/Frontend/Setup/Setup.hs new file mode 100644 index 000000000..5d150fed1 --- /dev/null +++ b/frontend/src/Frontend/Setup/Setup.hs @@ -0,0 +1,116 @@ +{-# LANGUAGE DataKinds #-} +{-# LANGUAGE FlexibleContexts #-} +{-# LANGUAGE GeneralizedNewtypeDeriving #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE RecursiveDo #-} +{-# LANGUAGE ScopedTypeVariables #-} +{-# LANGUAGE TypeApplications #-} +{-# LANGUAGE TypeFamilies #-} + +-- | Wallet setup screens +module Frontend.Setup.Setup (runSetup) where + +import Control.Lens ((<>~), (^.), _1, _2, _3) +import Control.Monad (guard) +import Control.Monad.Fix (MonadFix) +import Control.Monad.Trans.Maybe (MaybeT(MaybeT), runMaybeT) +import Data.Foldable (traverse_) +import Data.Maybe (isNothing) +import Language.Javascript.JSaddle (MonadJSM, liftJSM) +import Reflex.Dom.Core +import qualified Data.Text as T +import System.FilePath (takeFileName) + +import Frontend.AppCfg (FileFFI(..), FileType(FileType_Import)) +import Pact.Server.ApiClient (HasTransactionLogger, askTransactionLogger) +import Frontend.Storage.Class (HasStorage) +import Frontend.UI.Button +import Frontend.UI.Widgets.Helpers (imgWithAlt) +import Frontend.UI.Widgets +import Frontend.Setup.Widgets +import Frontend.Setup.Common +import Frontend.Setup.ImportExport (doImport, ImportWalletError(..), ImportWidgetApis(..), ImportWidgetConstraints) +import Frontend.Crypto.Class +import Frontend.Crypto.Password +import Obelisk.Generated.Static + +runSetup + :: forall t m n key bipStorage + . ( DomBuilder t m + , MonadFix m + , MonadHold t m + , PerformEvent t m + , PostBuild t m + , MonadJSM (Performable m) + , TriggerEvent t m + , HasStorage (Performable m) + , MonadSample t (Performable m) + , HasTransactionLogger m + , BIP39Root key, BIP39Mnemonic (Sentence key) + , ImportWidgetConstraints bipStorage key n (Performable m) + ) + => FileFFI t m + -> Bool + -> WalletExists + -> ImportWidgetApis bipStorage key n (Performable m) + -> m (Event t (Either () (key, Password, Bool))) +runSetup fileFFI showBackOverride walletExists importWidgetApis = setupDiv "fullscreen" $ mdo + let dCurrentScreen = (^._1) <$> dwf + + eBack <- fmap (domEvent Click . fst) $ elDynClass "div" ((setupClass "back " <>) . hideBack <$> dCurrentScreen) $ + el' "span" $ do + elClass "i" "fa fa-fw fa-chevron-left" $ blank + text "Back" + + _ <- dyn_ $ walletSetupRecoverHeader <$> dCurrentScreen + + dwf <- divClass "wrapper" $ + workflow (splashScreenWithImport walletExists fileFFI importWidgetApis eBack) + + pure $ leftmost + [ fmap Right $ switchDyn $ (^. _2) <$> dwf + , attachWithMaybe (\s () -> Left () <$ guard (s == WalletScreen_SplashScreen)) (current dCurrentScreen) eBack + , fmap Left $ switchDyn $ (^. _3) <$> dwf + ] + where + hideBack ws = + if not showBackOverride && (ws `elem` [WalletScreen_SplashScreen, WalletScreen_Done]) then + setupClass "hide" + else + setupScreenClass ws + +splashScreenWithImport + :: (DomBuilder t m, MonadFix m, MonadHold t m, PerformEvent t m + , PostBuild t m, MonadJSM (Performable m), TriggerEvent t m, HasStorage (Performable m) + , MonadSample t (Performable m) + , HasTransactionLogger m + , BIP39Root key, BIP39Mnemonic (Sentence key) + , ImportWidgetConstraints bipStorage key n (Performable m) + ) + => WalletExists + -> FileFFI t m + -> ImportWidgetApis bipStorage key n (Performable m) + -> Event t () + -> SetupWF key t m +splashScreenWithImport walletExists fileFFI importWidgetApis eBack = selfWF + where + selfWF = Workflow $ setupDiv "splash" $ do + agreed <- splashScreenAgreement + let hasAgreed = gate (current agreed) + disabledCfg = uiButtonCfg_disabled .~ fmap not agreed + restoreCfg = uiButtonCfg_class <>~ "setup__restore-existing-button" + + create <- confirmButton (def & disabledCfg ) "Create a new wallet" + + restoreBipPhrase <- uiButtonDyn (btnCfgSecondary & disabledCfg & restoreCfg) + $ text "Restore from recovery phrase" + + restoreImport <- uiButtonDyn (btnCfgSecondary & disabledCfg & restoreCfg) + $ text "Restore from wallet export" + + finishSetupWF WalletScreen_SplashScreen $ leftmost + [ createNewWallet selfWF eBack <$ hasAgreed create + , restoreBipWallet selfWF eBack <$ hasAgreed restoreBipPhrase + , restoreFromImport walletExists fileFFI importWidgetApis selfWF eBack <$ hasAgreed restoreImport + ]