core: wallet export and delete act on the device key

The key is one per device, so both commands read it directly instead of
going through the active profile's account. A profile without an account
of its own can now export, and the delete error says that the key goes for
every profile on the device. The confirmation word is lowercased, as the
phrase is when imported.

Two known limitations are written down: account indexes restart at 0 after
an import, so profiles are bound in the order they ask for a key, and the
gap left by a hidden profile in the printed indexes shows that it exists.

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-09 16:06:34 +00:00
co-authored by Claude Opus 5
parent 70d4c128bd
commit e5d18bd59e
5 changed files with 19 additions and 12 deletions
+7 -6
View File
@@ -58,7 +58,7 @@ import qualified Data.UUID.V4 as V4
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, getBoundAccount, getDeviceSeed, getOrCreateAccountRef, getSeedAccounts, importSeed)
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.Call
import Simplex.Chat.Controller
@@ -1496,7 +1496,8 @@ processChatCommand cxt nm = \case
accs <- case seed_ of
Nothing -> pure []
Just seed -> do
-- hidden profiles are left out, as they are by /users
-- hidden profiles are left out, as they are by /users, but the gap in
-- account indexes still shows that one exists
as <- filter (\(_, _, active, hidden) -> active || not hidden) <$> withFastStore' (\db -> getSeedAccounts db (wsId seed))
forM as $ \(n, acct, active, _) -> do
keys <- forM [0 .. walletNamesShown - 1] $ \k -> do
@@ -1515,17 +1516,17 @@ processChatCommand cxt nm = \case
when (isNothing r) $ throwCmdError "this device already has a wallet key"
processChatCommand cxt nm APIWallet
APIWalletExport -> withUser $ \user -> do
(seed, _) <- withFastStore' (\db -> getBoundAccount db user) >>= maybe (throwCmdError noKeyError) pure
seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure
phrase <- either (throwCmdError . ("wallet: " <>)) pure $ recoveryKeyPhrase seed
pure $ CRWalletPhrase user (safeDecodeUtf8 phrase)
APIWalletDelete confirmWord -> withUser $ \user -> do
seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure
phrase <- either (throwCmdError . ("wallet: " <>)) pure $ recoveryKeyPhrase seed
case reverse . T.words $ safeDecodeUtf8 phrase of
w : _ | w == confirmWord -> do
w : _ | w == T.toLower confirmWord -> do
withFastStore' $ \db -> deleteSeed db (wsId seed)
processChatCommand cxt nm APIWallet
_ -> throwCmdError "to confirm, pass the last word of the recovery phrase"
_ -> throwCmdError "this deletes the wallet key for all profiles on this device, to confirm pass the last word of the recovery phrase"
APISendCallInvitation contactId callType -> withUser $ \user -> do
-- party initiating call
ct <- withFastStore $ \db -> getContact db cxt user contactId
@@ -5467,7 +5468,7 @@ walletNamesShown :: NameIndex
walletNamesShown = 2
noKeyError :: String
noKeyError = "no wallet key for this profile"
noKeyError = "no wallet key on this device"
chatCommandP :: Parser ChatCommand
chatCommandP =
@@ -12,7 +12,8 @@ CREATE TABLE wallet_seeds (
wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT,
seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes
-- known issue: after import this starts at 0, so a recovered device can
-- re-issue an account that already owns names
-- re-issue an account that already owns names, and profiles are bound in
-- the order they ask for a key, not the order they had
next_account_index INTEGER NOT NULL DEFAULT 0,
-- one key per device for now
single_seed INTEGER NOT NULL DEFAULT 1 UNIQUE
@@ -858,7 +858,8 @@ CREATE TABLE wallet_seeds(
wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT,
seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes
-- known issue: after import this starts at 0, so a recovered device can
-- re-issue an account that already owns names
-- re-issue an account that already owns names, and profiles are bound in
-- the order they ask for a key, not the order they had
next_account_index INTEGER NOT NULL DEFAULT 0,
-- one key per device for now
single_seed INTEGER NOT NULL DEFAULT 1 UNIQUE
-1
View File
@@ -6,7 +6,6 @@
module Simplex.Chat.Store.Wallets
( getDeviceSeed,
getBoundAccount,
getSeedAccounts,
getOrCreateAccountRef,
importSeed,
+8 -3
View File
@@ -65,7 +65,7 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet"
alice <## "no wallet key"
alice ##> "/_wallet export"
alice <## "bad chat command: no wallet key for this profile"
alice <## "bad chat command: no wallet key on this device"
alice ##> "/_wallet create"
rows <- accountRows alice "alice, active" 0
map fst rows `shouldBe` ["m/44'/60'/0'/0/0", "m/44'/60'/0'/0/1"]
@@ -86,11 +86,16 @@ testWalletSecondProfile :: HasCallStack => TestParams -> IO ()
testWalletSecondProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create"
rows <- accountRows alice "alice, active" 0
alice ##> "/_wallet export"
phrase <- getTermLine alice
alice ##> "/create user alisa"
showActiveUser alice "alisa"
alice ##> "/_wallet"
_ <- accountRows alice "alice" 0
alice <## "this profile has no wallet key"
-- the key belongs to the device, so a profile without an account exports it too
alice ##> "/_wallet export"
alice <## phrase
alice ##> "/_wallet create"
_ <- accountRows alice "alice" 0
rows' <- accountRows alice "alisa, active" 1
@@ -115,8 +120,8 @@ testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet import " <> B.unpack testPhrase)
_ <- accountRows alice "alice, active" 0
alice ##> "/_wallet delete abandon"
alice <## "bad chat command: to confirm, pass the last word of the recovery phrase"
alice ##> "/_wallet delete about"
alice <## "bad chat command: this deletes the wallet key for all profiles on this device, to confirm pass the last word of the recovery phrase"
alice ##> "/_wallet delete About"
alice <## "no wallet key"
-- deleting unbinds the profile, so a key can be imported again
alice ##> ("/_wallet import " <> B.unpack testPhrase)