infallibel wallet and account derivation

This commit is contained in:
Alain Brenzikofer
2026-09-26 12:34:06 +02:00
parent 41d06dd4ea
commit 143d073980
10 changed files with 23 additions and 46 deletions
-4
View File
@@ -4668,10 +4668,6 @@ CounterUnknown:
AccountsExhausted:
- type: "accountsExhausted"
Derivation:
- type: "derivation"
- derivationError: string
---
+1 -1
View File
@@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd
source-repository-package
type: git
location: https://github.com/simplex-chat/simplexmq.git
tag: 88dd6c7041a49d6c15e3d83b49f581bbc3a9ac77
tag: 31360b40c4d3ede6baac78b00dec51df5812ba84
source-repository-package
type: git
+1 -2
View File
@@ -85,7 +85,7 @@ A command that acts on a profile's accounts names the profile and is rejected wh
`bind` without `account=` binds the account at a counter on the master, which is a high-water mark and not a count of bound accounts, and returns the bound account's index, path and address. With `account=<n>` it binds that account, which is how an account found by a scan is attached to the profile it belongs to, and it is rejected for an account another profile holds. After an import the counter is unknown rather than zero, because the phrase does not encode how many accounts it has been used for, so binding the next account is rejected until a scan sets the counter, while binding a known account is still allowed.
BIP-32 marks an index as hardened by setting its top bit, so an index at or above 2^31 already has that bit set and derives the same key as the index 2^31 below it: account 2^31 is account 0. That is a collision, not a loss of hardening, and it would put one key under two account indexes. An account index is a simplexmq type that only holds values below 2^31, so the command parser rejects 2^31 and above as a bad command, the columns have CHECK constraints for that bound, and reading a row that violates it is an error. The counter's bound is one higher than an account's, because it contains the next index to bind, and 2^31 there means the counter has passed every index that can be hardened, which `bind` without an index reports.
BIP-32 marks an index as hardened by setting its top bit, so an index at or above 2^31 already has that bit set and derives the same key as the index 2^31 below it: account 2^31 is account 0. That is a collision, not a loss of hardening, and it would put one key under two account indexes. Derivation itself cannot fail: for the one key in 2^128 that BIP-32 declares invalid, simplexmq recomputes it as SLIP-0010 specifies and Trezor implements, instead of skipping the index, so every account index has a key. An account index is a simplexmq type that only holds values below 2^31, so the command parser rejects 2^31 and above as a bad command, the columns have CHECK constraints for that bound, and reading a row that violates it is an error. The counter's bound is one higher than an account's, because it contains the next index to bind, and 2^31 there means the counter has passed every index that can be hardened, which `bind` without an index reports.
`address` reads the counter without changing it, so two calls return the same address, and it derives an address for an account the database has no row for, which a device that lost its database requires. One address per call is sufficient: a caller that scans the tree calls it in a loop.
@@ -103,7 +103,6 @@ data WalletError
| WEAccountNotHeld -- export account, on an account the profile does not hold
| WECounterUnknown -- the counter is not set yet, after an import
| WEAccountsExhausted -- bind without an index, when the counter has passed every index
| WEDerivation {derivationError :: String} -- BIP-32 or BIP-39 derivation failed
```
## Recovery
@@ -5316,7 +5316,6 @@ export type WalletError =
| WalletError.AccountNotHeld
| WalletError.CounterUnknown
| WalletError.AccountsExhausted
| WalletError.Derivation
export namespace WalletError {
export type Tag =
@@ -5328,7 +5327,6 @@ export namespace WalletError {
| "accountNotHeld"
| "counterUnknown"
| "accountsExhausted"
| "derivation"
interface Interface {
type: Tag
@@ -5365,11 +5363,6 @@ export namespace WalletError {
export interface AccountsExhausted extends Interface {
type: "accountsExhausted"
}
export interface Derivation extends Interface {
type: "derivation"
derivationError: string
}
}
export type XFTPErrorType =
@@ -3746,10 +3746,6 @@ class WalletError_counterUnknown(TypedDict):
class WalletError_accountsExhausted(TypedDict):
type: Literal["accountsExhausted"]
class WalletError_derivation(TypedDict):
type: Literal["derivation"]
derivationError: str
WalletError = (
WalletError_noMaster
| WalletError_masterExists
@@ -3759,10 +3755,9 @@ WalletError = (
| WalletError_accountNotHeld
| WalletError_counterUnknown
| WalletError_accountsExhausted
| WalletError_derivation
)
WalletError_Tag = Literal["noMaster", "masterExists", "badMnemonic", "hiddenProfile", "accountBound", "accountNotHeld", "counterUnknown", "accountsExhausted", "derivation"]
WalletError_Tag = Literal["noMaster", "masterExists", "badMnemonic", "hiddenProfile", "accountBound", "accountNotHeld", "counterUnknown", "accountsExhausted"]
class XFTPErrorType_BLOCK(TypedDict):
type: Literal["BLOCK"]
+1 -1
View File
@@ -1,5 +1,5 @@
{
"https://github.com/simplex-chat/simplexmq.git"."88dd6c7041a49d6c15e3d83b49f581bbc3a9ac77" = "1bi0f34c3jb74n61r4inv52k8rc2pq75xxzk2wj7702xb4kdd9dn";
"https://github.com/simplex-chat/simplexmq.git"."31360b40c4d3ede6baac78b00dec51df5812ba84" = "0srnp3lsc09r3y5jsy6f6snb5d678l6bxjpxk77dl0lqx2gx7d2j";
"https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38";
"https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d";
"https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";
+5 -6
View File
@@ -65,7 +65,7 @@ import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeIss
import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode)
import Simplex.Chat.Badges.Service (BadgeBalance (..), BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), BadgeStatement (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..), currentBadgeServiceVersion)
import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim)
import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress, WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, newSeedEntropy, newWalletMaster, seedMnemonic)
import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress, WalletError (..), accountSecret, deriveAccount, importWalletMaster, newWalletMaster, seedMnemonic)
import Simplex.Chat.Call
import Simplex.Chat.Controller
import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..))
@@ -1506,10 +1506,9 @@ processChatCommand cxt nm = \case
wallet_ <- withFastStore getWallet
when (isJust wallet_) $ throwWalletError WEMasterExists
-- the counter starts at 0 for a generated seed and is unknown for an imported one
(entropy, nextAccount) <- case mnemonic_ of
Nothing -> (,Just 0) <$> (asks random >>= atomically . newSeedEntropy)
Just phrase -> (,Nothing) <$> liftWallet (entropyFromMnemonic phrase)
master <- liftWallet $ newWalletMaster entropy
(master, nextAccount) <- case mnemonic_ of
Nothing -> (,Just 0) <$> (liftIO . newWalletMaster =<< asks random)
Just phrase -> (,Nothing) <$> liftWallet (importWalletMaster phrase)
created <- withFastStore' $ \db -> createWallet db master nextAccount
unless created $ throwWalletError WEMasterExists
pure $ CRWallet user (Just [])
@@ -6039,7 +6038,7 @@ withWalletStore action = liftWallet =<< withFastStore action
walletAccount :: Wallet -> AccountIndex -> CM (AccountKey, WalletAddress)
walletAccount Wallet {walletMaster} n = do
g <- asks random
liftError' (ChatError . CEWallet) (deriveAccount g walletMaster n)
liftIO $ deriveAccount g walletMaster n
chatCommandP :: Parser ChatCommand
chatCommandP =
+2 -2
View File
@@ -2813,7 +2813,8 @@ viewChatError isCmd logLevel testView = \case
SDEUnknownDomain -> "is not included in the connection link's profile"
in [plain $ "SimpleX name " <> strEncode domain <> " " <> reason]
CEWallet walletErr ->
let reason = case walletErr of
let reason :: Text
reason = case walletErr of
WENoMaster -> "this device has no wallet"
WEMasterExists -> "this device already has a wallet"
WEBadMnemonic -> "not a valid 24 word recovery phrase"
@@ -2822,7 +2823,6 @@ viewChatError isCmd logLevel testView = \case
WEAccountNotHeld -> "this profile does not hold this account"
WECounterUnknown -> "the next account is unknown after an import"
WEAccountsExhausted -> "every account index is used"
WEDerivation e -> "derivation failed: " <> T.pack e
in [plain $ "wallet: " <> reason]
CENotResolvedLocally -> ["no matching chat found, name resolution is disabled"]
CEUnsupportedConnReq -> [ "", "Connection link is not supported by the your app version, please ugrade it.", plain updateStr]
+10 -15
View File
@@ -6,9 +6,9 @@ module Simplex.Chat.Wallet
AccountKey,
WalletAddress (..),
WalletError (..),
newSeedEntropy,
entropyFromMnemonic,
newWalletMaster,
entropyFromMnemonic,
importWalletMaster,
seedMnemonic,
deriveAccount,
accountSecret,
@@ -16,11 +16,8 @@ module Simplex.Chat.Wallet
where
import Control.Concurrent.STM
import Control.Monad.Except
import Control.Monad.IO.Class (liftIO)
import Crypto.Random (ChaChaDRG)
import qualified Data.Aeson.TH as JQ
import Data.Bifunctor (first)
import qualified Data.ByteArray.Encoding as BAE
import Data.Text (Text)
import Data.Text.Encoding (decodeLatin1)
@@ -31,7 +28,6 @@ import qualified Simplex.Messaging.Crypto.Secp256k1 as S
import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Eth.Address (addressFromPrivateKey)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON)
import Simplex.Messaging.Util (liftError')
type AccountKey = S.Secp256k1PrivateKey
@@ -51,30 +47,29 @@ data WalletError
| WEAccountNotHeld
| WECounterUnknown
| WEAccountsExhausted
| WEDerivation {derivationError :: String}
deriving (Eq, Show)
masterStrength :: B39.EntropyStrength
masterStrength = B39.ES256
newSeedEntropy :: TVar ChaChaDRG -> STM B39.WalletEntropy
newSeedEntropy = B39.randomEntropy masterStrength
newWalletMaster :: TVar ChaChaDRG -> IO B32.WalletMaster
newWalletMaster g = (`B32.mkWalletMaster` "") <$> atomically (B39.randomEntropy masterStrength g)
entropyFromMnemonic :: Text -> Either WalletError B39.WalletEntropy
entropyFromMnemonic phrase = case B39.parsePhrase phrase of
Right ent | B39.entropyWordCount ent == 24 -> Right ent
_ -> Left WEBadMnemonic
newWalletMaster :: B39.WalletEntropy -> Either WalletError B32.WalletMaster
newWalletMaster ent = first WEDerivation $ B32.mkWalletMaster ent ""
importWalletMaster :: Text -> Either WalletError B32.WalletMaster
importWalletMaster phrase = (`B32.mkWalletMaster` "") <$> entropyFromMnemonic phrase
seedMnemonic :: B32.WalletMaster -> Text
seedMnemonic = decodeLatin1 . B39.entropyPhrase . B32.masterEntropy
deriveAccount :: TVar ChaChaDRG -> B32.WalletMaster -> AccountIndex -> IO (Either WalletError (AccountKey, WalletAddress))
deriveAccount g master n = runExceptT $ do
k <- B32.xkKey <$> liftError' WEDerivation (B32.derivePath g (B32.walletMasterKey master) path)
a <- liftIO $ addressFromPrivateKey g k
deriveAccount :: TVar ChaChaDRG -> B32.WalletMaster -> AccountIndex -> IO (AccountKey, WalletAddress)
deriveAccount g master n = do
k <- B32.xkKey <$> B32.derivePath g (B32.walletMasterKey master) path
a <- addressFromPrivateKey g k
pure (k, WalletAddress {accountIndex = n, keyPath = decodeLatin1 $ B32.renderPath path, address = decodeLatin1 $ strEncode a})
where
path = bip44Path Ethereum n
+2 -2
View File
@@ -33,12 +33,12 @@ testPhrase24 :: Text
testPhrase24 = T.unwords $ replicate 23 "abandon" <> ["art"]
walletMaster :: Text -> B32.WalletMaster
walletMaster phrase = either error id $ B32.mkWalletMaster (either error id $ B39.parsePhrase phrase) ""
walletMaster phrase = B32.mkWalletMaster (either error id $ B39.parsePhrase phrase) ""
walletAccount :: Text -> Word32 -> IO (AccountKey, WalletAddress)
walletAccount phrase n = do
g <- C.newRandom
either (error . show) id <$> deriveAccount g (walletMaster phrase) (fromJust $ mkAccountIndex n)
deriveAccount g (walletMaster phrase) (fromJust $ mkAccountIndex n)
addressFromSecret :: String -> IO String
addressFromSecret secret = do