diff --git a/cabal.project b/cabal.project index 95fbeeeb88..b9423c022f 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: 256ef554486e5de67401c3d9d0e5ce2c35402247 + tag: 4719c432589c7b2f964d10d0502ee77701ef78fe 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 0e55941c8e..9e9a48927d 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -4,7 +4,7 @@ The client has no static keys of its own for blockchain use cases. It requires keys at addresses it can derive again after a restart, after a database restore, or on a new device; otherwise whatever those addresses hold is lost with the device. -The first consumer is [public namespaces](./2026-05-21-public-namespaces.md): a name is a record on a public blockchain that contains the owner's address, and every name is owned by an account of its own, so one chat profile requires many accounts. The wallet is not specific to names. An account is an ordinary Ethereum account, and [stealth transfers](https://github.com/simplex-chat/simplex-chat/pull/7519) will use accounts in the same way. A device also contains several chat profiles, which are meant to stay isolated, and the one phrase the user writes down has to recover all of them. +The first consumer is [public namespaces](./2026-05-21-public-namespaces.md): a name is a record on a public blockchain that contains the owner's address, and every name is owned by an account of its own, so one chat profile requires many accounts. The wallet is not specific to names. An account is an ordinary Ethereum account, and [stealth transfers](https://github.com/simplex-chat/simplex-chat/pull/7519) will use accounts in the same way. A device also contains several chat profiles, which are meant to stay isolated, and the one phrase the user writes down has to recover the keys of all of them. Out of scope of this doc: buying a name, the names protocol, the registrar, signing, the chain scan and the command that records its results, importing an account key that the master does not derive, more than one master, and stealth keys. Buying is not implemented yet either, so key derivation is merged first and can be reviewed on its own. @@ -58,7 +58,7 @@ An account is bound to at most one chat profile, and a profile to any number of Accounts are allocated in order and never reused, because an account the device no longer tracks still owns whatever it holds, and an account can remain unbound. Nothing is bound when a profile is created; an account is bound on first use, when the user buys a name for a profile, so a device on which the user never buys anything has never derived a key. -No account is bound to a hidden profile, so a hidden profile cannot own a name. Two things would leak: the master derives every account, so whoever can use any profile can also derive a hidden profile's account keys; and a name is written into the profile's own database row and listed across the device, while a hidden profile is a filter on what is shown, not encryption. Closing either requires changes in the profiles and in the name record, not in the key layout. Incognito is a property of a connection in this app rather than of a profile, so there is nothing at this level to reject; an incognito connection has no profile of its own to bind an account to. +An account cannot be bound to a hidden profile, so no name can be bought for a hidden profile. Two things would leak: the master derives every account, so whoever can use any profile can also derive a hidden profile's account keys; and a name is written into the profile's own database row and listed across the device, while a hidden profile is a filter on what is shown, not encryption. Closing either requires changes in the profiles and in the name record, not in the key layout. Incognito is a property of a connection in this app rather than of a profile, so there is nothing at this level to reject; an incognito connection has no profile of its own to bind an account to. ## Commands @@ -83,13 +83,13 @@ A command that acts on a profile's accounts names the profile and is rejected wh `create` always names its source, so no key material is generated by typing a prefix. An imported phrase must be 24 BIP-39 English words with a valid checksum, normalised for case and spacing. There is no BIP-39 passphrase, because it would be a second secret to back up and losing it would look exactly like losing the phrase, so a phrase used with a passphrase elsewhere derives different keys here. -`bind` without an argument 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. +`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. Every index at or above 2^31 is rejected, including one read from the counter; the simplexmq function that builds the path rejects it too, and the columns have CHECK constraints for that bound, so later writes cannot exceed it. 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. `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. -`export account` is rejected unless the profile holds the account, so every exported key belongs to an account that is already bound, which `bind` without an index never returns: the counter is either past it or unknown. An export is a copy and not a transfer: the device still derives what it exported and can still sign with it, so giving an account key away leaves two parties able to act as its owner until whatever it holds is transferred on chain. Signing is not in this change, and when it is added it is a command here that signs and returns a signature, not `export account` followed by signing elsewhere, which would make the narrow export the ordinary path. `delete` leaves accounts registered to their addresses, recoverable only from the phrase. +`export account` is rejected unless the profile holds the account, so every exported key belongs to an account that is already bound, which `bind` without an index never returns: the counter is either past it or unknown. An export is a copy and not a transfer: the device still derives what it exported and can still sign with it, so giving an account key away leaves two parties able to act as its owner until whatever it holds is transferred on chain. Signing is not in this change, and when it is added it is a command here that signs and returns a signature, not `export account` followed by signing elsewhere, which would make the narrow export the ordinary path. `delete` leaves whatever the accounts own on chain, recoverable only from the phrase. ```haskell data WalletAddress = WalletAddress {accountIndex :: Word32, keyPath :: Text, address :: Text} @@ -114,7 +114,7 @@ Three cases, by how much of the database is restored. What the scan finds is unbound, and the user attaches each account to a profile with `bind account=`. The names those accounts own identify them, which is what makes the choice possible at all: the user chooses between names they recognise, not between numbers. Binding changes no key and signs nothing, because ownership does not change, only the profile under which the app shows the account. Pointing a name at that profile's address is a separate signed edit of the name's record. -**The database is restored from a backup.** It contains the accounts as of the backup and nothing written after it, so its counter is behind. A counter that is behind is worse than one that is unknown, because it appears valid, so `bind` returns an account that is already in use. Nothing here distinguishes a restored database from a current one, so clearing the counter is the responsibility of whatever restores a database, along with the scan that sets it again. +**The database is restored from a backup.** It contains the accounts as of the backup and nothing written after it, so its counter is behind if an account was bound after the backup. A counter that is behind is worse than one that is unknown, because it appears valid, so `bind` returns an account that is already in use. Nothing here distinguishes a restored database from a current one, so clearing the counter is the responsibility of whatever restores a database, along with the scan that sets it again. **The database is current.** It records which profile holds which account, so the user is asked nothing. @@ -144,7 +144,7 @@ Only entropy that nothing can derive is stored: the master, always 32 bytes, sin `users` is not changed: the mapping is stored in the account row, and the index on `user_id` is not unique, because a profile owns as many accounts as it owns names. One seed per device is enforced by `single_seed` and the unique index on it, which a later change removes with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop an inline constraint without rebuilding the table. Deleting the master deletes its account rows, because an account index without its entropy derives nothing. The migration has no down migration, because a down migration would delete the master entropy, which may have no other copy. A down migration runs when an older app opens a newer database and the user confirms "Downgrade and open chat", and the backup made then is overwritten by the next upgrade. Without a down migration the older app reports that the database is newer than the app, and changes nothing. -A null `account_index` marks an account whose key was imported rather than derived, which the master phrase does not recover and the schema must not suggest it does. Importing one is not implemented here; the column is nullable now so that a row written later is read correctly. That feature also requires storage for the imported secret and an optional link to a seed, because such an account belongs to no seed and must not be deleted with one; on SQLite, making `wallet_seed_id` nullable rebuilds the table. +A null `account_index` marks an account whose key was imported rather than derived. The master phrase does not recover such an account, and the schema must not suggest that it does. Importing one is not implemented here; the column is nullable now so that a row written later is read correctly. That feature also requires storage for the imported secret and an optional link to a seed, because such an account belongs to no seed and must not be deleted with one; on SQLite, making `wallet_seed_id` nullable rebuilds the table. ## Threat model @@ -152,7 +152,7 @@ A null `account_index` marks an account whose key was imported rather than deriv - **Someone with one account key.** Can act as that account's owner permanently, because an export is a copy and the device keeps deriving the same key. Cannot derive another account's key. - **A wallet the master phrase is imported into.** Enumerating BIP-44 accounts computes account extended public keys, and some wallets send them to a vendor, which gives that vendor every account on the device at once, across every profile. That is what an account for each name otherwise prevents. - **Whoever answers the recovery scan.** Receives every address the scan derives from the phrase, in one sequence of requests, so it can link every account on the device, across profiles, and recognise addresses that own nothing yet, which is where future accounts will be. `address` derives an address for any index directly from the master, so a caller can enumerate hidden profiles' addresses too. This is the largest privacy cost of the design. -- **A paired device.** Wallet commands are allowed from a paired device like other chat commands: `export master` returns the whole wallet, `create` on a device that has no seed creates a seed chosen by the paired device, and `delete` deletes the master entropy, which may have no other copy. +- **A paired device.** Wallet commands are allowed from a paired device like other chat commands: `export master` returns the whole wallet, `create mnemonic=` on a device that has no seed imports a phrase that the paired device sends, and `delete` deletes the master entropy, which may have no other copy. - **Someone reading the logs.** The core logs no command and no response, so a phrase passed to `create` is not written to any log. - **A page open in the user's browser.** The websocket server in `apps/simplex-chat/Server.hs`, which runs only with `--chat-server-port` and listens on 127.0.0.1, accepts any local connection, requires no token and checks no `Origin`, and websockets are not bound by the same origin policy, so any page loaded while that server runs can send `export master` and read the response. It also prints every command it receives, that phrase included. Both are properties of that server, which this change makes a more valuable target, and closing them requires changes to that server rather than to the wallet. @@ -165,8 +165,8 @@ A null `account_index` marks an account whose key was imported rather than deriv 5. **A profile hidden after an account was bound to it keeps its accounts.** The check runs at bind time only. 6. **Account indexes are not dense.** An account can be bound and never used, and a run of empty accounts ends the scan, so an account after a gap can be missed. 7. **Gas payments and discovery conflict.** If an account ever pays for anything, whatever funds it links accounts on chain. If it never pays, no wallet discovers it past account 0. -8. **Nothing records which layout a seed was used with.** A phrase used in another wallet may own accounts at paths this doc does not describe. -9. **A restored database binds an account that is already in use.** Its counter is below the highest index used, and nothing detects that, so a name can be bought with an account that already owns one until the scan resets the counter. +8. **Nothing records which layout a seed was used with.** Another wallet may have derived accounts from the phrase at paths this doc does not describe. +9. **`bind` on a restored database can return an account that is already in use.** Its counter can be below the highest index used, and nothing detects that, so a name can be bought with an account that already owns one until the scan resets the counter. ## Main files @@ -184,5 +184,5 @@ A null `account_index` marks an account whose key was imported rather than deriv 1. **Vectors.** The two addresses above are derived from `abandon ... about`, as is account 0's secret, pinned to the value another wallet shows for it. A 24 word phrase imported through the command derives a pinned address end to end, so a change of path fails here rather than in a release, and the account a command names is the account whose key is returned. 2. **Isolation.** Ten accounts' addresses are all different, and the paths of accounts 0 and 7 have a hardened account component. 3. **Rejections.** A second generate; a phrase that is not 24 valid words; `bind`, `delete` and `export master` on a device with no wallet; `bind` on a hidden profile, on an account another profile holds, and on an imported master whose counter is unknown; `export account` for an account the profile does not hold; index 2^31, on `address`, `bind` and `export account` alike; an index of 2^32 or more is rejected as a bad command. -4. **Binding and reads.** A profile binds several accounts, each `bind` returns the account it bound, and the profile exports its own; an account bound by index moves the counter past it so the next one does not collide, and never moves it back; `bind account=` attaches a scanned one; an account left unbound by deleting its profile is bound to another profile; and two consecutive `address` calls return the same address without changing the counter, and derive an address for an account with no row. +4. **Binding and reads.** Several accounts are bound to one profile, each `bind` returns the account it bound, and that profile's accounts can be exported; binding an account by index moves the counter past it so the next one does not collide, and never moves it back; `bind account=` binds an account after an import; an account left unbound by deleting its profile is bound to another profile; and two consecutive `address` calls return the same address without changing the counter, and derive an address for an account with no row. 5. **Encoding and persistence.** An account secret whose first byte is zero is rendered with 64 hex digits; the wallet, its accounts, the counter and the phrase persist across a restart; and deleting the wallet deletes its accounts and resets the counter. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index da0561a19c..2defe00188 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."256ef554486e5de67401c3d9d0e5ce2c35402247" = "1nw1y8apy8s63i2w9vapzyyhknxppq11c6hamsnz5agccp3mvhir"; + "https://github.com/simplex-chat/simplexmq.git"."4719c432589c7b2f964d10d0502ee77701ef78fe" = "014iy7a0jqxhky62140smhj1jsr24d1xlr94hcv1fpngw84bn0s4"; "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 396783d64c..f7ac66e26f 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1527,7 +1527,7 @@ processChatCommand cxt nm = \case unless held $ throwWalletError WEAccountNotHeld (k, a) <- seedAccount seed n pure $ CRWalletAccountSecret user a (accountSecret k) - APIDeleteWallet -> withUser $ \_ -> do + APIDeleteWallet -> withUser_ $ do deleted <- withFastStore' deleteWalletSeed unless deleted $ throwWalletError WENoMaster ok_ diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index 7fdc5e5548..6b4d2fb776 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -3,7 +3,6 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} -{-# LANGUAGE TypeApplications #-} module Simplex.Chat.Store.Wallets ( SeedId, @@ -25,7 +24,6 @@ import Control.Monad.IO.Class (liftIO) import qualified Data.ByteArray as BA import Data.ByteString (ByteString) import Data.Int (Int64) -import Data.Maybe (isJust) import Simplex.Chat.Wallet (AccountIndex, WalletError (..), checkAccountIndex) import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) @@ -57,7 +55,7 @@ getWalletSeed db = createWalletSeed :: DB.Connection -> BA.ScrubbedBytes -> Maybe AccountIndex -> IO Bool createWalletSeed db entropy nextAccount = - fmap isJust . maybeFirstRow (fromOnly @SeedId) $ + rowReturned $ DB.query db [sql| @@ -69,8 +67,7 @@ createWalletSeed db entropy nextAccount = deleteWalletSeed :: DB.Connection -> IO Bool deleteWalletSeed db = - fmap isJust . maybeFirstRow (fromOnly @SeedId) $ - DB.query_ db "DELETE FROM wallet_seeds RETURNING wallet_seed_id" + rowReturned $ DB.query_ db "DELETE FROM wallet_seeds RETURNING wallet_seed_id" resolveAccount :: DB.Connection -> Maybe AccountIndex -> IO (Either WalletError (WalletSeed, AccountIndex)) resolveAccount db accountIdx_ = runExceptT $ do @@ -110,7 +107,7 @@ bindAccount db userId accountIdx_ = runExceptT $ do setAccountUser :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bool setAccountUser db sId userId n = - fmap isJust . maybeFirstRow (fromOnly @Int64) $ + rowReturned $ DB.query db [sql| @@ -136,3 +133,6 @@ raiseNextAccount db sId n = insertAccount :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO () insertAccount db sId userId n = DB.execute db "INSERT INTO wallet_accounts (wallet_seed_id, account_index, user_id) VALUES (?, ?, ?)" (sId, n, userId) + +rowReturned :: IO [Only Int64] -> IO Bool +rowReturned = fmap (not . null) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 108ad05ba3..0882d700b2 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1114,18 +1114,6 @@ walletAddressRow :: WalletAddress -> StyledString walletAddressRow WalletAddress {accountIndex, keyPath, address} = plain $ tshow accountIndex <> " " <> keyPath <> " " <> address -walletErrorText :: WalletError -> Text -walletErrorText = \case - WENoMaster -> "this device has no wallet" - WEMasterExists -> "this device already has a wallet" - WEBadMnemonic -> "not a valid 24 word recovery phrase" - WEHiddenProfile -> "a hidden profile cannot hold an account" - WEAccountBound -> "another profile holds this account" - WEAccountNotHeld -> "this profile does not hold this account" - WECounterUnknown -> "the next account is unknown after an import" - WEIndexTooLarge -> "account index is too large to harden" - WEDerivation e -> "derivation failed: " <> T.pack e - viewContactsList :: [Contact] -> [StyledString] viewContactsList = let getLDN :: Contact -> ContactName @@ -2823,7 +2811,18 @@ viewChatError isCmd logLevel testView = \case SDENoValidLink -> "has no valid connection link" SDEUnknownDomain -> "is not included in the connection link's profile" in [plain $ "SimpleX name " <> strEncode domain <> " " <> reason] - CEWallet walletErr -> [plain $ "wallet: " <> walletErrorText walletErr] + CEWallet walletErr -> + let 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" + WEHiddenProfile -> "a hidden profile cannot hold an account" + WEAccountBound -> "another profile holds this account" + WEAccountNotHeld -> "this profile does not hold this account" + WECounterUnknown -> "the next account is unknown after an import" + WEIndexTooLarge -> "account index must be below 2^31" + 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] CEInvalidChatMessage Connection {connId} msgMeta_ msg e -> diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index 16e7218891..c79a63c39d 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -20,7 +20,7 @@ import Control.Monad.Except import Control.Monad.IO.Class (liftIO) import Crypto.Random (ChaChaDRG) import qualified Data.Aeson.TH as JQ -import Data.Bifunctor (bimap, first) +import Data.Bifunctor (bimap) import qualified Data.ByteArray as BA import qualified Data.ByteArray.Encoding as BAE import Data.Text (Text) @@ -32,6 +32,7 @@ import qualified Simplex.Messaging.Crypto.Secp256k1 as S import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Eth.Address (addressFromPrivateKey, ethereumPath) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) +import Simplex.Messaging.Util (liftEitherWith, liftError') type AccountIndex = Word32 @@ -80,9 +81,9 @@ seedMnemonic = bimap WEDerivation (decodeLatin1 . B39.mnemonicPhrase) . B39.entr deriveAccount :: BA.ScrubbedBytes -> AccountIndex -> IO (Either WalletError (AccountKey, WalletAddress)) deriveAccount entropy n = runExceptT $ do path <- liftEither $ accountPath n - m <- liftEither . first WEDerivation $ B39.entropyToMnemonic entropy - master <- ExceptT $ first WEDerivation <$> B32.masterKey (B39.mnemonicToSeed m "") - k <- ExceptT $ fmap B32.xkKey . first WEDerivation <$> B32.derivePath master path + m <- liftEitherWith WEDerivation $ B39.entropyToMnemonic entropy + master <- liftError' WEDerivation $ B32.masterKey (B39.mnemonicToSeed m "") + k <- B32.xkKey <$> liftError' WEDerivation (B32.derivePath master path) a <- liftIO $ addressFromPrivateKey k pure (k, WalletAddress {accountIndex = n, keyPath = decodeLatin1 $ B32.renderPath path, address = decodeLatin1 $ strEncode a}) diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 4386f507eb..81ae167f16 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -8,29 +8,28 @@ import ChatTests.Utils import Control.Monad (void) import qualified Data.ByteArray as BA import qualified Data.ByteArray.Encoding as BAE -import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Char (toUpper) import Data.Either (isRight) import Data.List (nub) +import Data.Text (Text) import qualified Data.Text as T import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress (..), WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, seedMnemonic) import qualified Simplex.Messaging.Crypto.BIP39 as B39 import qualified Simplex.Messaging.Crypto.Secp256k1 as S import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Eth.Address (addressFromPrivateKey) -import Simplex.Messaging.Util (safeDecodeUtf8) import Test.Hspec hiding (it) import qualified Test.Hspec as Hspec -testPhrase12 :: ByteString -testPhrase12 = B.unwords $ replicate 11 "abandon" <> ["about"] +testPhrase12 :: Text +testPhrase12 = T.unwords $ replicate 11 "abandon" <> ["about"] -testPhrase24 :: ByteString -testPhrase24 = B.unwords $ replicate 23 "abandon" <> ["art"] +testPhrase24 :: Text +testPhrase24 = T.unwords $ replicate 23 "abandon" <> ["art"] -seedEntropy :: ByteString -> BA.ScrubbedBytes -seedEntropy phrase = B39.mnemonicToEntropy . either error id $ B39.parseMnemonic (safeDecodeUtf8 phrase) +seedEntropy :: Text -> BA.ScrubbedBytes +seedEntropy phrase = B39.mnemonicToEntropy . either error id $ B39.parseMnemonic phrase walletAccount :: BA.ScrubbedBytes -> AccountIndex -> IO (AccountKey, WalletAddress) walletAccount entropy n = either (error . show) id <$> deriveAccount entropy n @@ -71,10 +70,10 @@ walletDerivationTests = do Hspec.it "rejects an account index at or above 2^31" $ (void <$> deriveAccount (seedEntropy testPhrase12) 2147483648) `shouldReturn` Left WEIndexTooLarge Hspec.it "round-trips the phrase it was imported from" $ - seedMnemonic (seedEntropy testPhrase24) `shouldBe` Right (safeDecodeUtf8 testPhrase24) + seedMnemonic (seedEntropy testPhrase24) `shouldBe` Right testPhrase24 Hspec.it "accepts only 24 words with a valid checksum" $ do - entropyFromMnemonic (safeDecodeUtf8 testPhrase24) `shouldSatisfy` isRight - entropyFromMnemonic (safeDecodeUtf8 testPhrase12) `shouldBe` Left WEBadMnemonic + entropyFromMnemonic testPhrase24 `shouldSatisfy` isRight + entropyFromMnemonic testPhrase12 `shouldBe` Left WEBadMnemonic entropyFromMnemonic (T.unwords $ replicate 24 "abandon") `shouldBe` Left WEBadMnemonic walletTests :: SpecWith TestParams @@ -111,12 +110,12 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice <## "wallet: this device already has a wallet" alice ##> "/_wallet delete" alice <## "ok" - alice ##> ("/_wallet create mnemonic=" <> B.unpack (B.unwords $ replicate 24 "abandon")) + alice ##> ("/_wallet create mnemonic=" <> unwords (replicate 24 "abandon")) alice <## "wallet: not a valid 24 word recovery phrase" - alice ##> ("/_wallet create mnemonic=" <> map toUpper (B.unpack testPhrase24)) + alice ##> ("/_wallet create mnemonic=" <> map toUpper (T.unpack testPhrase24)) alice <## "wallet, no accounts for this profile" alice ##> "/_wallet export master" - alice <## B.unpack testPhrase24 + alice <## T.unpack testPhrase24 testWalletBind :: HasCallStack => TestParams -> IO () testWalletBind ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -197,10 +196,10 @@ testWalletAddress ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do testWalletExport :: HasCallStack => TestParams -> IO () testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase24) + alice ##> ("/_wallet create mnemonic=" <> T.unpack testPhrase24) alice <## "wallet, no accounts for this profile" alice ##> "/_wallet export master" - alice <## B.unpack testPhrase24 + alice <## T.unpack testPhrase24 alice ##> "/_wallet bind 1 account=0" alice `accountBound` "0" alice ##> "/_wallet bind 1 account=1" @@ -221,7 +220,7 @@ testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do testWalletImport :: HasCallStack => TestParams -> IO () testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase24) + alice ##> ("/_wallet create mnemonic=" <> T.unpack testPhrase24) alice <## "wallet, no accounts for this profile" alice ##> "/_wallet bind 1" alice <## "wallet: the next account is unknown after an import" @@ -298,12 +297,16 @@ testWalletIndexTooLarge ps = withNewTestChat ps "alice" aliceProfile $ \alice -> alice ##> "/_wallet create new" alice <## "wallet, no accounts for this profile" alice ##> "/_wallet address account=2147483648" - alice <## "wallet: account index is too large to harden" + alice <## "wallet: account index must be below 2^31" alice ##> "/_wallet bind 1 account=2147483648" - alice <## "wallet: account index is too large to harden" + alice <## "wallet: account index must be below 2^31" alice ##> "/_wallet export account 1 2147483648" - alice <## "wallet: account index is too large to harden" + alice <## "wallet: account index must be below 2^31" + alice ##> "/_wallet address account=4294967296" + alice <## "bad chat command: Failed reading: empty" + alice ##> "/_wallet bind 1 account=4294967296" + alice <## "bad chat command: Failed reading: empty" alice ##> "/_wallet bind 1 account=2147483647" alice `accountBound` "2147483647" alice ##> "/_wallet bind 1" - alice <## "wallet: account index is too large to harden" + alice <## "wallet: account index must be below 2^31"