Files
simplex-chat/tests/WalletTests.hs
T
Alain BrenzikoferandClaude Opus 5 619434626c core: store the seed as bytes, and bind one account per profile
On Postgres a bare ByteString binds as a text literal, so entropy with a
backslash or a high bit was rejected and entropy with a zero byte was
truncated: the BIP-39 test vector stored as an empty seed. Blob columns go
through DB.Binary here, as every other one does.

/_wallet bind with no account moved a profile that already had one to a
fresh account, abandoning the old one without saying so. It refuses now;
moving is asked for by number.

The counter could walk past 2^31, where BIP-32 hardening folds every index
back onto a low one, so a profile handed 2147483648 derived account 0's
keys and printed account 0's path. Binding refuses once the counter is
there, and a very long index is now rejected on its digit count rather than
after reading it.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
2026-09-10 14:28:06 +00:00

270 lines
12 KiB
Haskell

{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE PostfixOperators #-}
module WalletTests where
import ChatClient
import ChatTests.DBUtils
import ChatTests.Utils
import Data.ByteString.Char8 (ByteString)
import qualified Data.ByteString.Char8 as B
import Data.Either (isLeft)
import Data.List (intersect, nub)
import Simplex.Chat.Wallet (AccountIndex, NameIndex, WalletSeed (..), deriveNameKey, importRecoveryKey, nameKeySecret, recoveryKeyPhrase, renderNameKeyPath, seedMaster)
import qualified Simplex.Messaging.Crypto.Secp256k1 as S
import Simplex.Messaging.Eth.Address (addressFromPrivateKey)
import Test.Hspec hiding (it)
import qualified Test.Hspec as Hspec
-- | The standard BIP-39 test vector, so the addresses can be checked elsewhere.
testPhrase :: ByteString
testPhrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
testSeed :: WalletSeed
testSeed = WalletSeed {wsId = 1, wsEntropy = either error id $ importRecoveryKey testPhrase}
nameKey :: AccountIndex -> NameIndex -> Either String S.PrivateKey
nameKey acc nm = seedMaster testSeed >>= \m -> deriveNameKey m acc nm
walletDerivationTests :: Spec
walletDerivationTests = do
Hspec.it "name keys line up with other wallets' derivation" $ do
let addrOf i k = either error (show . addressFromPrivateKey) (nameKey i k)
-- MetaMask accounts 1 and 2 for this phrase
addrOf 0 0 `shouldBe` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"
addrOf 0 1 `shouldBe` "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0"
-- Ledger Live account 2 for this phrase
addrOf 1 0 `shouldBe` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265"
Hspec.it "derives the same secret as other wallets" $
-- MetaMask account 1 for this phrase, as exported by "Show private key"
either error nameKeySecret (nameKey 0 0)
`shouldBe` "0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727"
Hspec.it "renders the path a name key sits at" $ do
renderNameKeyPath 0 0 `shouldBe` "m/44'/60'/0'/0/0"
renderNameKeyPath 2 7 `shouldBe` "m/44'/60'/2'/0/7"
Hspec.it "round-trips the phrase it was imported from" $
recoveryKeyPhrase testSeed `shouldBe` Right testPhrase
Hspec.it "refuses a phrase with a bad checksum" $
importRecoveryKey (B.unwords $ replicate 12 "abandon") `shouldSatisfy` isLeft
walletTests :: SpecWith TestParams
walletTests = do
it "creates no key until asked, then derives addresses" testWalletCreate
it "the key and the addresses come back after a restart" testWalletPersists
it "a second profile gets its own account, on the same key" testWalletSecondProfile
it "imports a phrase, exports it, and refuses a second import" testWalletImport
it "exports the secret of any name key" testWalletExportDerivedSecret
it "deletes the key, and a key can be imported again" testWalletDelete
it "binds a profile to the account it had" testWalletBind
it "binds a profile once, and only to an account BIP-32 can harden" testWalletBindLimits
it "needs no import when the database was backed up after the key" testWalletBackupAfterKey
it "rebinds by index when the database was backed up before the key" testWalletBackupBeforeKey
it "discards a key imported before the database is restored" testWalletImportThenRestore
nameRows :: HasCallStack => TestCC -> IO [(String, String)]
nameRows cc = mapM (\_ -> nameRow <$> getTermLine cc) [0 .. 1 :: Int]
where
nameRow l = case words l of
["name", _, path, addr] -> (path, addr)
_ -> error $ "unexpected wallet row: " <> l
testWalletCreate :: HasCallStack => TestParams -> IO ()
testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet"
alice <## "no wallet key"
-- asking creates nothing
alice ##> "/_wallet"
alice <## "no wallet key"
alice ##> "/_wallet export"
alice <## "bad chat command: no wallet key on this device"
alice ##> "/_wallet create"
alice <## "no account for this profile"
alice ##> "/_wallet bind"
rows <- nameRows alice
map fst rows `shouldBe` ["m/44'/60'/0'/0/0", "m/44'/60'/0'/0/1"]
length (nub $ map snd rows) `shouldBe` 2
testWalletPersists :: HasCallStack => TestParams -> IO ()
testWalletPersists ps = do
rows <- withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create"
alice <## "no account for this profile"
alice ##> "/_wallet bind"
nameRows alice
-- same database, new session: a name bought at that address must stay reachable
withTestChat ps "alice" $ \alice -> do
alice ##> "/_wallet"
rows' <- nameRows alice
rows' `shouldBe` rows
testWalletSecondProfile :: HasCallStack => TestParams -> IO ()
testWalletSecondProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create"
alice <## "no account for this profile"
alice ##> "/_wallet bind"
rows <- nameRows alice
alice ##> "/_wallet export"
phrase <- getTermLine alice
alice ##> "/create user alisa"
showActiveUser alice "alisa"
-- other profiles are named, never numbered
alice ##> "/_wallet"
alice <## "no account for this profile"
alice <## "also on same seed: alice"
-- the key belongs to the device, so a profile without an account exports it too
alice ##> "/_wallet export"
alice <## phrase
alice ##> "/_wallet create"
alice <## "bad chat command: this device already has a wallet key"
alice ##> "/_wallet bind"
rows' <- nameRows alice
alice <## "also on same seed: alice"
map fst rows' `shouldBe` ["m/44'/60'/1'/0/0", "m/44'/60'/1'/0/1"]
null (map snd rows `intersect` map snd rows') `shouldBe` True
testWalletImport :: HasCallStack => TestParams -> IO ()
testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
-- import binds nothing: which account a profile had is what it is recovering
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice <## "no account for this profile"
alice ##> "/_wallet bind"
alice <## "name 0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94"
alice <## "name 1 m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0"
alice ##> "/_wallet export"
alice <## B.unpack testPhrase
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice <## "bad chat command: this device already has a wallet key"
-- a mistyped phrase says nothing about which word was wrong
alice ##> ("/_wallet import " <> B.unpack (B.unwords $ replicate 12 "abandon"))
alice <## "bad chat command: bad recovery phrase"
testWalletExportDerivedSecret :: HasCallStack => TestParams -> IO ()
testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
-- the secret of a name key needs no profile bound to that account
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice <## "no account for this profile"
alice ##> "/_wallet export 0 0"
alice <## "m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727"
-- an index BIP-32 cannot harden is rejected, not wrapped into another account
alice ##> "/_wallet export 4294967296 0"
alice <## "bad chat command: Failed reading: empty"
-- any path derives, whether or not a profile holds that account
alice ##> "/_wallet export 3 7"
alice <## "m/44'/60'/3'/0/7 0xb8cb8628d242fF621adb05E75b7bF16c9b496740 0x5fa3f03c127d150c82f54291f9989c955c3857a54df7abbb50e28199a0bbaac1"
-- a secret whose first byte is zero keeps its 64 hex digits
alice ##> "/_wallet export 0 15"
alice <## "m/44'/60'/0'/0/15 0xa25d37554EB084969C85362f7E6B1A6108e51d0e 0x009a1ccd9c667416d9db6246a35d022b1799517c0cd8547bb07ce280c119ae3c"
testWalletDelete :: HasCallStack => TestParams -> IO ()
testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice <## "no account for this profile"
alice ##> "/_wallet bind"
_ <- nameRows alice
alice ##> "/_wallet delete"
alice <## "no wallet key"
-- deleting unbinds the profile, so a key can be imported again
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice <## "no account for this profile"
-- | A profile says which account was its, as nothing else knows.
testWalletBind :: HasCallStack => TestParams -> IO ()
testWalletBind ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice <## "no account for this profile"
alice ##> "/_wallet bind 3"
rows <- nameRows alice
map fst rows `shouldBe` ["m/44'/60'/3'/0/0", "m/44'/60'/3'/0/1"]
alice ##> "/create user alisa"
showActiveUser alice "alisa"
alice ##> "/_wallet bind 3"
alice <## "bad chat command: another profile uses this account"
-- the counter moved past the account bound by hand
alice ##> "/_wallet bind"
rows' <- nameRows alice
alice <## "also on same seed: alice"
map fst rows' `shouldBe` ["m/44'/60'/4'/0/0", "m/44'/60'/4'/0/1"]
-- | The state a chat database backed up before the key restores to.
forgetKey :: HasCallStack => TestCC -> IO ()
forgetKey cc = do
cc ##> "/sql chat UPDATE users SET wallet_seed_id = NULL, wallet_account_index = NULL"
cc ##> "/sql chat DELETE FROM wallet_seeds"
testWalletBackupAfterKey :: HasCallStack => TestParams -> IO ()
testWalletBackupAfterKey ps = do
rows <- withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create"
alice <## "no account for this profile"
alice ##> "/_wallet bind"
nameRows alice
-- the key and the binding are both in the database, so the restore is all of it
withTestChat ps "alice" $ \alice -> do
alice ##> "/_wallet"
rows' <- nameRows alice
rows' `shouldBe` rows
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice <## "bad chat command: this device already has a wallet key"
testWalletBackupBeforeKey :: HasCallStack => TestParams -> IO ()
testWalletBackupBeforeKey ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice <## "no account for this profile"
alice ##> "/_wallet bind 1"
aliceRows <- nameRows alice
alice ##> "/create user alisa"
showActiveUser alice "alisa"
alice ##> "/_wallet bind 0"
alisaRows <- nameRows alice
alice <## "also on same seed: alice"
forgetKey alice
alice ##> "/_wallet"
alice <## "no wallet key"
-- the phrase alone puts every account back, and each profile says which was its
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice <## "no account for this profile"
alice ##> "/_wallet bind 0"
alisaRows' <- nameRows alice
alisaRows' `shouldBe` alisaRows
alice ##> "/user alice"
showActiveUser alice "alice (Alice)"
alice ##> "/_wallet bind 1"
aliceRows' <- nameRows alice
alice <## "also on same seed: alisa"
aliceRows' `shouldBe` aliceRows
testWalletImportThenRestore :: HasCallStack => TestParams -> IO ()
testWalletImportThenRestore ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice <## "no account for this profile"
-- restoring the database replaces the key with what the backup held, which is nothing
forgetKey alice
alice ##> "/_wallet"
alice <## "no wallet key"
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice <## "no account for this profile"
alice ##> "/_wallet bind"
rows <- nameRows alice
map fst rows `shouldBe` ["m/44'/60'/0'/0/0", "m/44'/60'/0'/0/1"]
testWalletBindLimits :: HasCallStack => TestParams -> IO ()
testWalletBindLimits ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> ("/_wallet import " <> B.unpack testPhrase)
alice <## "no account for this profile"
alice ##> "/_wallet bind"
_ <- nameRows alice
-- a profile that has an account asks for another one by number
alice ##> "/_wallet bind"
alice <## "bad chat command: this profile already has an account"
alice ##> "/create user alisa"
showActiveUser alice "alisa"
alice ##> "/_wallet bind 2147483647"
rows <- nameRows alice
alice <## "also on same seed: alice"
map fst rows `shouldBe` ["m/44'/60'/2147483647'/0/0", "m/44'/60'/2147483647'/0/1"]
-- the counter is past what BIP-32 can harden, where it would repeat account 0
alice ##> "/create user carol"
showActiveUser alice "carol"
alice ##> "/_wallet bind"
alice <## "bad chat command: no free account on this key"