mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 17:58:47 +00:00
core: export the secret of a single name key
/_wallet export gives the seed mnemonic, /_wallet export <account> <name> gives the secret of one derived key, so a single name can be handed over without the seed. Both are what a wallet takes on import: the mnemonic as a recovery phrase, the secret as hex. The commands are named for what they return, APIWalletExportSeedMnemonic and APIWalletExportDerivedSecret, as the difference is which secret leaves the device. Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
This commit is contained in:
co-authored by
Claude Opus 5
parent
e7f5908d01
commit
db1b6413e0
@@ -453,7 +453,8 @@ undocumentedCommands =
|
||||
"APIWallet",
|
||||
"APIWalletCreate",
|
||||
"APIWalletDelete",
|
||||
"APIWalletExport",
|
||||
"APIWalletExportDerivedSecret",
|
||||
"APIWalletExportSeedMnemonic",
|
||||
"APIWalletImport",
|
||||
"CheckChatRunning",
|
||||
"ConfirmRemoteCtrl",
|
||||
|
||||
@@ -213,6 +213,7 @@ undocumentedResponses =
|
||||
"CRUserServersValidation",
|
||||
"CRVersionInfo",
|
||||
"CRWallet",
|
||||
"CRWalletPhrase",
|
||||
"CRWalletDerivedSecret",
|
||||
"CRWalletSeedMnemonic",
|
||||
"CRWelcome"
|
||||
]
|
||||
|
||||
@@ -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]}
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
+22
-1
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user