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
+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)