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:
Alain Brenzikofer
2026-09-10 10:11:29 +00:00
co-authored by Claude Opus 5
parent e7f5908d01
commit db1b6413e0
7 changed files with 50 additions and 12 deletions
+2 -1
View File
@@ -453,7 +453,8 @@ undocumentedCommands =
"APIWallet",
"APIWalletCreate",
"APIWalletDelete",
"APIWalletExport",
"APIWalletExportDerivedSecret",
"APIWalletExportSeedMnemonic",
"APIWalletImport",
"CheckChatRunning",
"ConfirmRemoteCtrl",
+2 -1
View File
@@ -213,6 +213,7 @@ undocumentedResponses =
"CRUserServersValidation",
"CRVersionInfo",
"CRWallet",
"CRWalletPhrase",
"CRWalletDerivedSecret",
"CRWalletSeedMnemonic",
"CRWelcome"
]
+7 -4
View File
@@ -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]}
+9 -4
View File
@@ -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),
+2 -1
View File
@@ -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
+6
View File
@@ -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
View File
@@ -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)