From 143d0739808797b34b42d97bb99b3d152c3adcef Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Sat, 26 Sep 2026 12:34:06 +0200 Subject: [PATCH] infallibel wallet and account derivation --- bots/api/TYPES.md | 4 --- cabal.project | 2 +- docs/rfcs/2026-09-10-wallet-keys.md | 3 +-- .../types/typescript/src/types.ts | 7 ------ .../src/simplex_chat/types/_types.py | 7 +----- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Library/Commands.hs | 11 ++++---- src/Simplex/Chat/View.hs | 4 +-- src/Simplex/Chat/Wallet.hs | 25 ++++++++----------- tests/WalletTests.hs | 4 +-- 10 files changed, 23 insertions(+), 46 deletions(-) diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index d01fc3b4b4..7b7920690c 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -4668,10 +4668,6 @@ CounterUnknown: AccountsExhausted: - type: "accountsExhausted" -Derivation: -- type: "derivation" -- derivationError: string - --- diff --git a/cabal.project b/cabal.project index df722f9005..d30ed247cb 100644 --- a/cabal.project +++ b/cabal.project @@ -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 diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 4619c1229e..d11fdd44c7 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -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=` 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 diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 9cfc5f40c5..11bc46d7be 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -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 = diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index a945f984a1..00983026fe 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -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"] diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index fd3bf524ad..ffd091b489 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -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"; diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 46dc2173ec..5ddd65f259 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -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 = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5e50c4bf13..ae217b9dfc 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -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] diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index 0cfb909416..b5cfd922d2 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -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 diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 3d96874c30..f013af1102 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -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