diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index c08e63fe0c..0be6b110a7 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -453,7 +453,8 @@ undocumentedCommands = "APIWallet", "APIWalletCreate", "APIWalletDelete", - "APIWalletExport", + "APIWalletExportDerivedSecret", + "APIWalletExportSeedMnemonic", "APIWalletImport", "CheckChatRunning", "ConfirmRemoteCtrl", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 913e57b432..9facd2fd3e 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -213,6 +213,7 @@ undocumentedResponses = "CRUserServersValidation", "CRVersionInfo", "CRWallet", - "CRWalletPhrase", + "CRWalletDerivedSecret", + "CRWalletSeedMnemonic", "CRWelcome" ] diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 7339c18200..d9b71084da 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -68,7 +68,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Chat.Wallet (AccountIndex) +import Simplex.Chat.Wallet (AccountIndex, NameIndex) import Simplex.Chat.Util (liftIOEither) import Simplex.FileTransfer.Description (FileDescriptionURI) import Simplex.Messaging.Server.Information (ServerPublicInfo) @@ -420,7 +420,8 @@ data ChatCommand | APIWallet | APIWalletCreate | APIWalletImport {recoveryPhrase :: Text} - | APIWalletExport + | APIWalletExportSeedMnemonic + | APIWalletExportDerivedSecret {accountIndex :: AccountIndex, nameIndex :: NameIndex} | APIWalletDelete | APISendCallInvitation ContactId CallType | SendCallInvitation ContactName CallType @@ -750,7 +751,8 @@ allowRemoteCommand = \case APIWallet -> False APIWalletCreate -> False APIWalletImport _ -> False - APIWalletExport -> False + APIWalletExportSeedMnemonic -> False + APIWalletExportDerivedSecret {} -> False APIWalletDelete -> False _ -> True @@ -854,7 +856,8 @@ data ChatResponse | CRServiceResponse {user :: User, responseData :: J.Object} | CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId} | CRWallet {user :: User, walletKeyExists :: Bool, walletAccounts :: [(Text, AccountIndex, Bool, [(Text, Text)])]} - | CRWalletPhrase {user :: User, recoveryPhrase :: Text} + | CRWalletSeedMnemonic {user :: User, recoveryPhrase :: Text} + | CRWalletDerivedSecret {user :: User, keyPath :: Text, address :: Text, derivedSecret :: Text} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool} | CRGroupsList {user :: User, groups :: [GroupInfo]} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 7f6d41c61e..6f5845c1e7 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -59,7 +59,7 @@ import Simplex.Chat.Library.Subscriber import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), badgeServerCredential, maxXFTPFileSize, mkBadgeStatus, verifyCredential) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) import Simplex.Chat.Store.Wallets (deleteSeed, getDeviceSeed, getOrCreateAccountRef, getSeedAccounts, importSeed) -import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), accountAddress, deriveNameKey, importRecoveryKey, newSeed, recoveryKeyPhrase, renderNameKeyPath) +import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), accountAddress, accountSecret, deriveNameKey, importRecoveryKey, newSeed, recoveryKeyPhrase, renderNameKeyPath) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -1515,10 +1515,14 @@ processChatCommand cxt nm = \case r <- withFastStore' $ \db -> importSeed db user entropy when (isNothing r) $ throwCmdError "this device already has a wallet key" processChatCommand cxt nm APIWallet - APIWalletExport -> withUser $ \user -> do + APIWalletExportSeedMnemonic -> withUser $ \user -> do seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure phrase <- either (throwCmdError . ("wallet: " <>)) pure $ recoveryKeyPhrase seed - pure $ CRWalletPhrase user (safeDecodeUtf8 phrase) + pure $ CRWalletSeedMnemonic user (safeDecodeUtf8 phrase) + APIWalletExportDerivedSecret acct nameIdx -> withUser $ \user -> do + seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure + acc <- either (throwCmdError . ("wallet: " <>)) pure $ deriveNameKey seed acct nameIdx + pure $ CRWalletDerivedSecret user (renderNameKeyPath acct nameIdx) (tshow $ accountAddress acc) (safeDecodeUtf8 $ accountSecret acc) APIWalletDelete -> withUser $ \_ -> do seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure withFastStore' $ \db -> deleteSeed db (wsId seed) @@ -5586,7 +5590,8 @@ chatCommandP = "/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP), "/_wallet create" $> APIWalletCreate, "/_wallet import " *> (APIWalletImport <$> textP), - "/_wallet export" $> APIWalletExport, + "/_wallet export " *> (APIWalletExportDerivedSecret <$> A.decimal <* A.space <*> A.decimal), + "/_wallet export" $> APIWalletExportSeedMnemonic, "/_wallet delete" $> APIWalletDelete, "/_wallet" $> APIWallet, "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP), diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index b770f46231..8f598340f1 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -199,7 +199,8 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te plain ("account " <> tshow acct <> " (" <> n <> (if active then ", active" else "") <> ")") : zipWith nameRow [0 :: Int ..] keys nameRow k (path, addr) = plain $ " name " <> tshow k <> " " <> path <> " " <> addr - CRWalletPhrase u phrase -> ttyUser u [plain phrase] + CRWalletSeedMnemonic u phrase -> ttyUser u [plain phrase] + CRWalletDerivedSecret u path addr secret -> ttyUser u [plain $ path <> " " <> addr <> " " <> secret] CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreationFailed u results -> ttyUser u $ viewPublicGroupCreationFailed results diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index 458a3e9a54..b7c78bd0d0 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -17,11 +17,13 @@ module Simplex.Chat.Wallet deriveNameKey, renderNameKeyPath, accountAddress, + accountSecret, ) where import Control.Concurrent.STM import Crypto.Random (ChaChaDRG) +import qualified Data.ByteArray.Encoding as BAE import Data.ByteString (ByteString) import Data.Int (Int64) import Data.Text (Text) @@ -91,3 +93,7 @@ deriveNameKey s acc nm = do accountAddress :: WalletAccount -> Address accountAddress = addressFromPrivateKey . waKey + +-- | Hex, as wallets take it when a key is imported on its own. +accountSecret :: WalletAccount -> ByteString +accountSecret a = "0x" <> BAE.convertToBase BAE.Base16 (S.unPrivateKey $ waKey a) diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index cfce4383da..e070732712 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -10,7 +10,7 @@ import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Either (isLeft) import Data.List (intersect, nub) -import Simplex.Chat.Wallet (SeedId (..), WalletSeed (..), accountAddress, deriveNameKey, importRecoveryKey, recoveryKeyPhrase, renderNameKeyPath) +import Simplex.Chat.Wallet (SeedId (..), WalletSeed (..), accountAddress, accountSecret, deriveNameKey, importRecoveryKey, recoveryKeyPhrase, renderNameKeyPath) import Test.Hspec hiding (it) import qualified Test.Hspec as Hspec @@ -32,6 +32,10 @@ walletDerivationTests = do addrOf 0 1 `shouldBe` "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" -- Ledger Live account 2 for this phrase addrOf 1 0 `shouldBe` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265" + Hspec.it "derives the same secret as other wallets" $ + -- MetaMask account 1 for this phrase, as exported by "Show private key" + either error (show . accountSecret) (deriveNameKey testSeed 0 0) + `shouldBe` "\"0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727\"" Hspec.it "renders the path a name key sits at" $ do renderNameKeyPath 0 0 `shouldBe` "m/44'/60'/0'/0/0" renderNameKeyPath 2 7 `shouldBe` "m/44'/60'/2'/0/7" @@ -46,6 +50,7 @@ walletTests = do it "the key and the addresses come back after a restart" testWalletPersists it "a second profile gets its own account, on the same key" testWalletSecondProfile it "imports a phrase, exports it, and refuses a second import" testWalletImport + it "exports the secret of any name key" testWalletExportDerivedSecret it "deletes the key, and a key can be imported again" testWalletDelete accountRows :: HasCallStack => TestCC -> String -> Int -> IO [(String, String)] @@ -115,6 +120,22 @@ testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet import " <> B.unpack (B.unwords $ replicate 12 "abandon")) alice <## "bad chat command: bad recovery phrase" +testWalletExportDerivedSecret :: HasCallStack => TestParams -> IO () +testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> ("/_wallet import " <> B.unpack testPhrase) + _ <- accountRows alice "alice, active" 0 + alice ##> "/_wallet export 0 0" + alice <## "m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727" + -- any path derives, whether or not a profile holds that account + alice ##> "/_wallet export 3 7" + l <- getTermLine alice + case words l of + [path, addr, secret] -> do + path `shouldBe` "m/44'/60'/3'/0/7" + length addr `shouldBe` 42 + length secret `shouldBe` 66 + _ -> error $ "unexpected export row: " <> l + testWalletDelete :: HasCallStack => TestParams -> IO () testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet import " <> B.unpack testPhrase)