mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-28 04:48:58 +00:00
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
270 lines
12 KiB
Haskell
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"
|