/goal iteration 5 - unconfirmed

This commit is contained in:
Alain Brenzikofer
2026-09-21 17:03:54 +02:00
parent bfc4e1fbaa
commit 768a230f21
14 changed files with 328 additions and 238 deletions
-2
View File
@@ -628,9 +628,7 @@ deriving instance Generic UserContactRequest
deriving instance Generic UserInfo
deriving instance Generic UserProfileUpdateSummary
deriving instance Generic UserPwdHash
deriving instance Generic WalletError
deriving instance Generic XFTPErrorType
deriving instance Generic XFTPRcvFile
deriving instance Generic XFTPSndFile
+13 -10
View File
@@ -110,7 +110,7 @@ Three cases, by how much of the database came back.
What the scan finds is unbound, and the user attaches each account to a profile with `bind account=<n>`. The names those accounts own are what identify them, which is what makes the question answerable at all: the user is choosing between names they recognise, not between numbers. Binding moves no key and signs nothing, because ownership does not change, only which profile the app shows the account under. Pointing a name at that profile's address is a separate signed edit of the name's record.
**The database is older than the master.** It has 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 looks usable, so such a database is treated as having an unknown counter until the same scan fills the gap.
**The database is older than the master.** It has 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 looks usable and hands out an account the master has already used. Nothing here can tell a restored database from a current one, so clearing the counter belongs to whatever restores one, along with the scan that fills it in again.
**The database is current.** It records which profile holds which account, so nothing is asked of the user.
@@ -132,13 +132,13 @@ CREATE TABLE wallet_accounts (
);
CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON wallet_seeds(single_seed);
CREATE UNIQUE INDEX idx_wallet_accounts_index ON wallet_accounts(wallet_seed_id, account_index);
CREATE INDEX idx_wallet_accounts_user ON wallet_accounts(user_id);
CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_accounts(wallet_seed_id, account_index);
CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id);
```
Only entropy that nothing can derive is stored: the master, always 32 bytes, since it is made and imported as 24 words. An account key is never stored, because the master entropy and an account index derive it whenever one is needed. So `wallet_accounts` holds what derivation cannot produce, which account indexes the device knows about and which profile each belongs to. A row with no `user_id` is an account no profile holds, which is what a deleted chat profile leaves behind and what a scan writes.
`users` is not touched: the mapping lives on 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 `single_seed` and the unique index on it, which a later change lifts with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop one of those without rebuilding the table. Deleting the master takes its account rows, because an account index with no entropy behind it derives nothing. The migration does have a reverse step, which the schema test exercises, and running it destroys the only copy of the master entropy, so it is for development and never for a device holding anything.
`users` is not touched: the mapping lives on 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 `single_seed` and the unique index on it, which a later change lifts with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop one of those without rebuilding the table. Deleting the master takes its account rows, because an account index with no entropy behind it derives nothing. The migration has no reverse step, because reversing it would drop the only copy of the master entropy. What runs a reverse step is an older app installed over a newer database, which on mobile happens without asking and leaves one backup file that the next upgrade overwrites; with no reverse step that older app reports instead that the database is newer than it is, 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 reads correctly, rather than leaving an unmarked row to be guessed at.
@@ -149,7 +149,8 @@ A null `account_index` marks an account whose key was imported rather than deriv
- **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 hands 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.** Sees every address the phrase could hold a name on, in one burst, so it links every account on the device, across profiles, and recognises addresses that hold nothing yet, which is where future accounts will be. `address` derives for any index straight from the master, so a caller can enumerate hidden profiles' addresses too. This is the sharpest cost in the design.
- **A paired device.** Can run any of these commands, because they are not blocked from one: `export master` reads the whole wallet, `create` on a device that has none plants a seed the pairing controls, and `delete` destroys the only copy. Blocking `ExecChatStoreSQL` while allowing `export master` is not a coherent line, and the wallet commands need their own decision rather than the catch-all.
- **Someone reading the logs.** A remote session logs a command's verb and nothing else, so a phrase typed into `create` stays out of the log, and an answer is never logged at all. The websocket server in `apps/simplex-chat/Server.hs` prints every command it receives, that phrase included, which is a change to that server rather than to the wallet.
- **Someone reading the logs.** A remote session logs a command's verb and nothing else, so a phrase typed into `create` stays out of the log, and an answer is never logged at all.
- **A page open in the user's browser.** The websocket server in `apps/simplex-chat/Server.hs` accepts any local connection, asks for 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 answer. It also prints every command it receives, that phrase included. Both are properties of that server, which this change gives something worth taking, and closing them is work there rather than in the wallet.
## Known limits
@@ -161,7 +162,8 @@ A null `account_index` marks an account whose key was imported rather than deriv
6. **Account indexes are not dense.** An account can be taken and never used, and a run of empty accounts is how the scan stops, so one far above a gap can be missed.
7. **Gas and discovery pull against each other.** If an account ever pays for anything, whatever funds it links accounts on chain. If it never pays, no wallet finds it past account 0.
8. **Nothing records which layout a seed was used under.** A phrase used in another wallet may hold accounts at paths this doc does not describe.
9. **Purpose `5564'` is ours.** Reserved for stealth keys, taken from an [ERC-5564](https://eips.ethereum.org/EIPS/eip-5564) number rather than registered as a BIP-43 purpose, and nothing here derives at it.
9. **A restored database hands out an account that is already used.** Its counter is behind what the master has reached, and nothing detects that, so a name can be bought with an account that already owns one until the scan resets the counter.
10. **Purpose `5564'` is ours.** Reserved for stealth keys, taken from an [ERC-5564](https://eips.ethereum.org/EIPS/eip-5564) number rather than registered as a BIP-43 purpose, and nothing here derives at it.
## Files
@@ -169,14 +171,15 @@ A null `account_index` marks an account whose key was imported rather than deriv
- `src/Simplex/Chat/Store/Wallets.hs`, the two tables.
- `src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs` and the Postgres twin.
- `tests/WalletTests.hs`.
- `tests/SchemaDump.hs` and `tests/PostgresSchemaDump.hs`, which selected what to test by taking every migration after the last one without a reverse step, and now take every migration from the first one that has a reverse step, applying any that has none.
- Derivation uses the `BIP32` and `BIP39` modules already in simplexmq and adds no dependency.
## What is verified
**File:** `tests/WalletTests.hs`. Each of these is a test, not a claim.
1. **Vectors.** The two addresses above reproduce from `abandon ... about`, as does account 0's secret, pinned to the value another wallet shows for it. A 24 word phrase imported through the command reaches a pinned address end to end, so a change of path fails here rather than shipping.
1. **Vectors.** The two addresses above reproduce from `abandon ... about`, as does account 0's secret, pinned to the value another wallet shows for it. A 24 word phrase imported through the command reaches a pinned address end to end, so a change of path fails here rather than shipping, and the account a command names is the account whose key comes back.
2. **Isolation.** Ten accounts' addresses are all different, and an account path hardens its account component.
3. **Refusals.** A second generate; a phrase that is not 24 valid words; `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 another profile holds; every index at or above 2^31, on `address`, `bind` and `export account` alike.
4. **Binding and reads.** A profile binds several accounts, an account bound by index moves the counter past it so the next one does not collide, `bind account=<n>` attaches a scanned one, and `address` returns the counter twice running without moving it and derives for an account with no row.
5. **Encoding and persistence.** An account secret whose first byte is zero keeps its 64 hex digits, and the wallet, its accounts and the phrase survive a restart.
3. **Refusals.** 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 another profile holds; every index at or above 2^31, on `address`, `bind` and `export account` alike.
4. **Binding and reads.** A profile binds several accounts and 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=<n>` attaches a scanned one, an account a deleted profile leaves behind is taken by another profile, and `address` returns the counter twice running without moving it and derives for an account with no row.
5. **Encoding and persistence.** An account secret whose first byte is zero keeps its 64 hex digits; the wallet, its accounts, the counter and the phrase survive a restart; and deleting the wallet takes its accounts and starts the counter over.
+17 -27
View File
@@ -58,8 +58,8 @@ import qualified Data.UUID.V4 as V4
import Simplex.Chat.Library.Subscriber
import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), badgeServerCredential, maxXFTPFileSize, mkBadgeStatus, verifyCredential)
import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim)
import Simplex.Chat.Store.Wallets (accountHeldByOther, bindAccount, createWalletSeed, deleteWalletSeed, getNextAccountIndex, getUserAccounts, getWalletSeed)
import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress (..), WalletError (..), WalletSeed (..), accountSecret, checkAccountIndex, deriveAccountKey, entropyFromMnemonic, newSeedEntropy, renderAccountPath, seedMaster, seedMnemonic)
import Simplex.Chat.Store.Wallets (WalletSeed (..), accountHeldByOther, bindAccount, createWalletSeed, deleteWalletSeed, getUserAccounts, getWalletSeed, resolveAccount)
import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress (..), WalletError (..), accountSecret, checkAccountIndex, deriveAccountKey, entropyFromMnemonic, newSeedEntropy, renderAccountPath, seedMaster, seedMnemonic)
import Simplex.Messaging.Eth.Address (addressFromPrivateKey)
import Simplex.Chat.Call
import Simplex.Chat.Controller
@@ -1503,27 +1503,25 @@ processChatCommand cxt nm = \case
unless created $ throwWalletError WEMasterExists
processChatCommand cxt nm APIGetWallet
APIBindWalletAccount accountIdx_ -> withUser $ \User {userId, viewPwdHash} -> do
seed <- walletSeed
when (isJust viewPwdHash) $ throwWalletError WEHiddenProfile
_ <- liftWallet =<< withFastStore' (\db -> bindAccount db (wsId seed) userId accountIdx_)
liftWallet =<< withFastStore' (\db -> bindAccount db userId accountIdx_)
processChatCommand cxt nm APIGetWallet
APIGetWalletAddress accountIdx_ -> withUser $ \user -> do
seed <- walletSeed
n <- resolveAccount seed accountIdx_
(seed, n) <- liftWallet =<< withFastStore' (`resolveAccount` accountIdx_)
CRWalletAddress user . accountAddress n <$> accountKey seed n
APIExportWalletMnemonic -> withUser $ \user ->
CRWalletMnemonic user <$> (liftWallet . seedMnemonic =<< walletSeed)
APIExportWalletAccount accountIdx -> withUser $ \user@User {userId} -> do
CRWalletMnemonic user <$> (liftWallet . seedMnemonic . wsEntropy =<< walletSeed)
APIExportWalletAccount n -> withUser $ \user@User {userId} -> do
seed <- walletSeed
n <- resolveAccount seed (Just accountIdx)
liftWallet $ checkAccountIndex n
-- a key another profile holds is not this profile's to hand out
heldByOther <- withFastStore' $ \db -> accountHeldByOther db (wsId seed) userId n
when heldByOther $ throwWalletError WEAccountBound
k <- accountKey seed n
pure $ CRWalletAccountSecret user (accountAddress n k) (accountSecret k)
APIDeleteWallet -> withUser $ \_ -> do
seed <- walletSeed
withFastStore' $ \db -> deleteWalletSeed db (wsId seed)
deleted <- withFastStore' deleteWalletSeed
unless deleted $ throwWalletError WENoMaster
ok_
APISendCallInvitation contactId callType -> withUser $ \user -> do
-- party initiating call
@@ -5470,19 +5468,8 @@ throwWalletError = throwChatError . CEWallet
liftWallet :: Either WalletError a -> CM a
liftWallet = either throwWalletError pure
-- | The account a command names, or the next free one when it names none.
-- Refuses any index BIP-32 cannot harden, the counter's included.
resolveAccount :: WalletSeed -> Maybe AccountIndex -> CM AccountIndex
resolveAccount seed accountIdx_ = do
n <- maybe nextFreeAccount pure accountIdx_
n <$ liftWallet (checkAccountIndex n)
where
nextFreeAccount =
withFastStore' (\db -> getNextAccountIndex db (wsId seed))
>>= maybe (throwWalletError WECounterUnknown) pure
accountKey :: WalletSeed -> AccountIndex -> CM AccountKey
accountKey seed n = liftWallet $ seedMaster seed >>= (`deriveAccountKey` n)
accountKey seed n = liftWallet $ seedMaster (wsEntropy seed) >>= (`deriveAccountKey` n)
accountAddress :: AccountIndex -> AccountKey -> WalletAddress
accountAddress n k =
@@ -6156,12 +6143,15 @@ chatCommandP =
quotedP = safeDecodeUtf8 <$> (A.char '"' *> A.takeTill (== '"') <* A.char '"')
text1P = safeDecodeUtf8 <$> A.takeTill (== ' ')
char_ = optional . A.char
-- Digits are counted before they are read, as reading a very long number is
-- not free. The hardening bound is a typed error when the command runs.
-- The digits are counted before they are read, as reading a very long
-- number is not free. Ten of them fit an Int with room to spare, and the
-- hardening bound is a typed error when the command runs, so that a caller
-- is told which index was refused and why.
accountIndexP = do
ds <- A.takeWhile1 isDigit
let i = read (B.unpack ds) :: Integer
if B.length ds <= 10 && i <= toInteger (maxBound :: AccountIndex) then pure (fromIntegral i) else fail "account index too large"
case if B.length ds <= 10 then B.readInt ds else Nothing of
Just (i, _) | i <= fromIntegral (maxBound :: AccountIndex) -> pure $ fromIntegral i
_ -> fail "account index too large"
displayNameP :: Parser Text
displayNameP = safeDecodeUtf8 <$> displayNameP_
@@ -99,7 +99,7 @@ schemaMigrations =
("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations),
("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link),
("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry),
("20260908_wallet_seeds", m20260908_wallet_seeds, Just down_m20260908_wallet_seeds)
("20260908_wallet_seeds", m20260908_wallet_seeds, Nothing)
]
-- | The list of migrations in ascending order by date
@@ -9,10 +9,10 @@ import Text.RawString.QQ (r)
m20260908_wallet_seeds :: Text
m20260908_wallet_seeds =
[r|
-- the columns are commented in the SQLite migration
CREATE TABLE wallet_seeds (
wallet_seed_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
entropy BYTEA NOT NULL CHECK (length(entropy) = 32),
-- see the SQLite migration
next_account_index BIGINT CHECK (next_account_index BETWEEN 0 AND 2147483648),
single_seed SMALLINT NOT NULL DEFAULT 1
);
@@ -25,20 +25,8 @@ CREATE TABLE wallet_accounts (
);
CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON wallet_seeds(single_seed);
CREATE UNIQUE INDEX idx_wallet_accounts_index ON wallet_accounts(wallet_seed_id, account_index);
CREATE INDEX idx_wallet_accounts_user ON wallet_accounts(user_id);
CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_accounts(wallet_seed_id, account_index);
CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id);
|]
down_m20260908_wallet_seeds :: Text
down_m20260908_wallet_seeds =
[r|
DROP INDEX idx_wallet_accounts_user;
DROP INDEX idx_wallet_accounts_index;
DROP INDEX idx_wallet_seeds_single_seed;
DROP TABLE wallet_accounts;
DROP TABLE wallet_seeds;
|]
-- no reverse step, see the SQLite migration
@@ -1533,11 +1533,34 @@ ALTER TABLE test_chat_schema.users ALTER COLUMN user_id ADD GENERATED ALWAYS AS
CREATE TABLE test_chat_schema.wallet_accounts (
wallet_account_id bigint NOT NULL,
wallet_seed_id bigint NOT NULL,
account_index bigint,
user_id bigint,
CONSTRAINT wallet_accounts_account_index_check CHECK (((account_index >= 0) AND (account_index <= 2147483647)))
);
ALTER TABLE test_chat_schema.wallet_accounts ALTER COLUMN wallet_account_id ADD GENERATED ALWAYS AS IDENTITY (
SEQUENCE NAME test_chat_schema.wallet_accounts_wallet_account_id_seq
START WITH 1
INCREMENT BY 1
NO MINVALUE
NO MAXVALUE
CACHE 1
);
CREATE TABLE test_chat_schema.wallet_seeds (
wallet_seed_id bigint NOT NULL,
entropy bytea NOT NULL,
next_name_index bigint DEFAULT 1 NOT NULL,
single_seed smallint DEFAULT 1 NOT NULL
next_account_index bigint,
single_seed smallint DEFAULT 1 NOT NULL,
CONSTRAINT wallet_seeds_entropy_check CHECK ((length(entropy) = 32)),
CONSTRAINT wallet_seeds_next_account_index_check CHECK (((next_account_index >= 0) AND (next_account_index <= '2147483648'::bigint)))
);
@@ -1911,6 +1934,11 @@ ALTER TABLE ONLY test_chat_schema.users
ALTER TABLE ONLY test_chat_schema.wallet_accounts
ADD CONSTRAINT wallet_accounts_pkey PRIMARY KEY (wallet_account_id);
ALTER TABLE ONLY test_chat_schema.wallet_seeds
ADD CONSTRAINT wallet_seeds_pkey PRIMARY KEY (wallet_seed_id);
@@ -2665,6 +2693,14 @@ CREATE UNIQUE INDEX idx_user_contact_links_group_id ON test_chat_schema.user_con
CREATE INDEX idx_wallet_accounts_user_id ON test_chat_schema.wallet_accounts USING btree (user_id);
CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON test_chat_schema.wallet_accounts USING btree (wallet_seed_id, account_index);
CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON test_chat_schema.wallet_seeds USING btree (single_seed);
@@ -3344,6 +3380,16 @@ ALTER TABLE ONLY test_chat_schema.user_contact_links
ALTER TABLE ONLY test_chat_schema.wallet_accounts
ADD CONSTRAINT wallet_accounts_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE SET NULL;
ALTER TABLE ONLY test_chat_schema.wallet_accounts
ADD CONSTRAINT wallet_accounts_wallet_seed_id_fkey FOREIGN KEY (wallet_seed_id) REFERENCES test_chat_schema.wallet_seeds(wallet_seed_id) ON DELETE CASCADE;
ALTER TABLE ONLY test_chat_schema.xftp_file_descriptions
ADD CONSTRAINT xftp_file_descriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE;
+1 -1
View File
@@ -345,7 +345,7 @@ schemaMigrations =
("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations),
("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link),
("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry),
("20260908_wallet_seeds", m20260908_wallet_seeds, Just down_m20260908_wallet_seeds)
("20260908_wallet_seeds", m20260908_wallet_seeds, Nothing)
]
-- | The list of migrations in ascending order by date
@@ -23,20 +23,11 @@ CREATE TABLE wallet_accounts (
) STRICT;
CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON wallet_seeds(single_seed);
CREATE UNIQUE INDEX idx_wallet_accounts_index ON wallet_accounts(wallet_seed_id, account_index);
CREATE INDEX idx_wallet_accounts_user ON wallet_accounts(user_id);
CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_accounts(wallet_seed_id, account_index);
CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id);
|]
down_m20260908_wallet_seeds :: Query
down_m20260908_wallet_seeds =
[sql|
DROP INDEX idx_wallet_accounts_user;
DROP INDEX idx_wallet_accounts_index;
DROP INDEX idx_wallet_seeds_single_seed;
DROP TABLE wallet_accounts;
DROP TABLE wallet_seeds;
|]
-- There is no reverse step. Reversing this would drop the only copy of the
-- master entropy, and a downgrade that takes every key on the device with it is
-- worse than one that refuses: without a reverse step the older app reports that
-- the database is newer than it is and changes nothing.
@@ -1399,11 +1399,11 @@ CREATE INDEX idx_chat_items_item_signed_by_group_member_id ON chat_items(
item_signed_by_group_member_id
);
CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON wallet_seeds(single_seed);
CREATE UNIQUE INDEX idx_wallet_accounts_index ON wallet_accounts(
CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_accounts(
wallet_seed_id,
account_index
);
CREATE INDEX idx_wallet_accounts_user ON wallet_accounts(user_id);
CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id);
CREATE TRIGGER on_group_members_insert_update_summary
AFTER INSERT ON group_members
FOR EACH ROW
+64 -26
View File
@@ -1,28 +1,31 @@
{-# LANGUAGE CPP #-}
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE NamedFieldPuns #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
{-# LANGUAGE TypeApplications #-}
-- | The device seed, and which chat profile each account belongs to.
module Simplex.Chat.Store.Wallets
( getWalletSeed,
( SeedId,
WalletSeed (..),
getWalletSeed,
createWalletSeed,
deleteWalletSeed,
getNextAccountIndex,
resolveAccount,
getUserAccounts,
accountHeldByOther,
bindAccount,
)
where
import Control.Monad (join, when)
import Control.Monad (join, unless, when)
import Control.Monad.Except
import Control.Monad.IO.Class (liftIO)
import qualified Data.ByteArray as BA
import Data.ByteString (ByteString)
import Data.Int (Int64)
import Simplex.Chat.Wallet (AccountIndex, SeedId, WalletError (..), WalletSeed (..), checkAccountIndex)
import Simplex.Chat.Wallet (AccountIndex, WalletError (..), checkAccountIndex)
import Simplex.Messaging.Agent.Protocol (UserId)
import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.DB as DB
@@ -35,6 +38,17 @@ import Database.SQLite.Simple (Only (..))
import Database.SQLite.Simple.QQ (sql)
#endif
type SeedId = Int64
-- | The device seed, as a row: the id the account rows are counted against, and
-- the entropy every key is derived from. The entropy is 'BA.ScrubbedBytes', so
-- a derived 'Show' does not print it.
data WalletSeed = WalletSeed
{ wsId :: SeedId,
wsEntropy :: BA.ScrubbedBytes
}
deriving (Show)
toSeed :: (Int64, ByteString) -> WalletSeed
toSeed (sId, entropy) = WalletSeed {wsId = sId, wsEntropy = BA.convert entropy}
@@ -56,8 +70,26 @@ createWalletSeed db entropy nextAccount =
"INSERT INTO wallet_seeds (entropy, next_account_index) VALUES (?, ?)"
(DB.Binary (BA.convert entropy :: ByteString), accountIndexCol <$> nextAccount)
deleteWalletSeed :: DB.Connection -> SeedId -> IO ()
deleteWalletSeed db sId = DB.execute db "DELETE FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId)
-- | False if the device had no seed to delete. The account rows go with it.
deleteWalletSeed :: DB.Connection -> IO Bool
deleteWalletSeed db =
getWalletSeed db >>= \case
Nothing -> pure False
Just WalletSeed {wsId} ->
True <$ DB.execute db "DELETE FROM wallet_seeds WHERE wallet_seed_id = ?" (Only wsId)
-- | The seed, and the account an argument names or the next free one from the
-- counter when it names none. Refuses an index BIP-32 cannot harden, the
-- counter's included. The seed comes back with it so that a caller derives from
-- the one the index was resolved against.
resolveAccount :: DB.Connection -> Maybe AccountIndex -> IO (Either WalletError (WalletSeed, AccountIndex))
resolveAccount db accountIdx_ = runExceptT $ do
seed <- ExceptT $ maybe (Left WENoMaster) Right <$> getWalletSeed db
n <- maybe (nextFreeAccount $ wsId seed) pure accountIdx_
liftEither $ checkAccountIndex n
pure (seed, n)
where
nextFreeAccount sId = ExceptT $ maybe (Left WECounterUnknown) Right <$> getNextAccountIndex db sId
-- | The index the next account takes. Nothing after an import, where the phrase
-- does not say how many accounts it has been used for.
@@ -87,9 +119,10 @@ accountUser db sId n =
maybeFirstRow (fromOnly @(Maybe Int64)) $
DB.query db "SELECT user_id FROM wallet_accounts WHERE wallet_seed_id = ? AND account_index = ?" (sId, accountIndexCol n)
-- | True when a profile other than this one holds the account, which is what
-- keeps one profile from exporting another profile's key. An account no profile
-- holds is not another profile's.
-- | True when a profile other than this one holds the account, which keeps one
-- profile from handing out another's account key. It is a guard, not a
-- boundary: @export master@ reaches every account from any profile. An account
-- no profile holds is not another profile's.
heldByOther :: UserId -> Maybe (Maybe Int64) -> Bool
heldByOther userId = \case
Just (Just heldBy) -> heldBy /= userId
@@ -99,32 +132,37 @@ accountHeldByOther :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bo
accountHeldByOther db sId userId n = heldByOther userId <$> accountUser db sId n
-- | Bind an account to a profile: one the device already knows about, one a
-- scan found, or the next free one when no index is given. Reading the counter
-- and taking the account happen in one transaction, so two binds racing cannot
-- both take it.
bindAccount :: DB.Connection -> SeedId -> UserId -> Maybe AccountIndex -> IO (Either WalletError AccountIndex)
bindAccount db sId userId accountIdx_ = runExceptT $ do
n <- maybe nextFreeAccount pure accountIdx_
liftEither $ checkAccountIndex n
-- scan found, or the next free one when no index is given. The whole command is
-- one transaction, so nothing between reading the counter and taking the
-- account can hand the same one out twice.
bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> IO (Either WalletError ())
bindAccount db userId accountIdx_ = runExceptT $ do
(WalletSeed {wsId = sId}, n) <- ExceptT $ resolveAccount db accountIdx_
held <- liftIO $ accountUser db sId n
when (heldByOther userId held) $ throwError WEAccountBound
liftIO $ do
case held of
Just (Just _) -> pure () -- already this profile's
Just Nothing -> setAccountUser db sId userId n
Nothing -> insertAccount db sId userId n
raiseNextAccount db sId n
pure n
where
nextFreeAccount = ExceptT $ maybe (Left WECounterUnknown) Right <$> getNextAccountIndex db sId
taken <- liftIO $ case held of
Just (Just _) -> pure True -- already this profile's
-- the update takes the account only while no profile holds it, and the read
-- after it says whether this one got it: where transactions are not
-- serialised, two of them can both read the account as free
Just Nothing -> setAccountUser db sId userId n >> accountHeldBy db sId userId n
Nothing -> True <$ insertAccount db sId userId n
unless taken $ throwError WEAccountBound
liftIO $ raiseNextAccount db sId n
setAccountUser :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO ()
setAccountUser db sId userId n =
DB.execute
db
[sql| UPDATE wallet_accounts SET user_id = ? WHERE wallet_seed_id = ? AND account_index = ? |]
[sql|
UPDATE wallet_accounts SET user_id = ?
WHERE wallet_seed_id = ? AND account_index = ? AND user_id IS NULL
|]
(userId, sId, accountIndexCol n)
accountHeldBy :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bool
accountHeldBy db sId userId n = (== Just (Just userId)) <$> accountUser db sId n
-- | Keep the counter a high-water mark, so an account taken by index is not
-- handed out again as the next free one. Never lowers it, and never gives a
-- value to the imported phrase that has none.
+10 -23
View File
@@ -10,10 +10,8 @@
-- Nothing here knows about chat profiles. Which profile an account belongs to is
-- a mapping in "Simplex.Chat.Store.Wallets".
module Simplex.Chat.Wallet
( SeedId,
AccountIndex,
( AccountIndex,
AccountKey,
WalletSeed (..),
WalletAddress (..),
WalletError (..),
newSeedEntropy,
@@ -33,7 +31,6 @@ import qualified Data.Aeson.TH as JQ
import qualified Data.ByteArray as BA
import qualified Data.ByteArray.Encoding as BAE
import Data.ByteString (ByteString)
import Data.Int (Int64)
import Data.Text (Text)
import Data.Text.Encoding (decodeLatin1)
import Data.Word (Word32)
@@ -43,8 +40,6 @@ import qualified Simplex.Messaging.Crypto.Secp256k1 as S
import Simplex.Messaging.Eth.Address (ethereumPath)
import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON)
type SeedId = Int64
-- | BIP-44 account index. One account owns one thing on chain, one name to
-- begin with.
type AccountIndex = Word32
@@ -52,15 +47,6 @@ type AccountIndex = Word32
-- | The key at an account index. It owns whatever that account owns.
type AccountKey = S.PrivateKey
-- | The device seed. The entropy is 'BA.ScrubbedBytes', so a derived 'Show'
-- does not print it. Copies made for BIP-39 are plain 'ByteString' and are not
-- wiped.
data WalletSeed = WalletSeed
{ wsId :: SeedId,
wsEntropy :: BA.ScrubbedBytes
}
deriving (Eq, Show)
-- | One derived address, with the index it came from, so a caller that left the
-- index out knows what it got.
data WalletAddress = WalletAddress
@@ -101,14 +87,14 @@ entropyFromMnemonic phrase = case B39.parseMnemonic phrase of
Right . BA.convert $ B39.mnemonicToEntropy m
_ -> Left WEBadMnemonic
seedMnemonic :: WalletSeed -> Either WalletError Text
seedMnemonic s =
bipError . fmap (decodeLatin1 . B39.mnemonicPhrase) . B39.entropyToMnemonic $ entropyBytes s
seedMnemonic :: BA.ScrubbedBytes -> Either WalletError Text
seedMnemonic entropy =
bipError . fmap (decodeLatin1 . B39.mnemonicPhrase) . B39.entropyToMnemonic $ entropyBytes entropy
-- | Deriving this runs PBKDF2, so it is done once per command.
seedMaster :: WalletSeed -> Either WalletError B32.ExtendedKey
seedMaster s = do
m <- bipError . B39.entropyToMnemonic $ entropyBytes s
seedMaster :: BA.ScrubbedBytes -> Either WalletError B32.ExtendedKey
seedMaster entropy = do
m <- bipError . B39.entropyToMnemonic $ entropyBytes entropy
bipError . B32.masterKey $ B39.mnemonicToSeed m ""
accountPath :: AccountIndex -> [Word32]
@@ -124,8 +110,9 @@ deriveAccountKey master n = B32.xkKey <$> bipError (B32.derivePath master $ acco
accountSecret :: AccountKey -> Text
accountSecret k = "0x" <> decodeLatin1 (BAE.convertToBase BAE.Base16 $ S.unPrivateKey k)
entropyBytes :: WalletSeed -> ByteString
entropyBytes = BA.convert . wsEntropy
-- | The copy BIP-39 takes is a plain 'ByteString' and is not wiped.
entropyBytes :: BA.ScrubbedBytes -> ByteString
entropyBytes = BA.convert
-- | The BIP-32 and BIP-39 functions report failure as a string. For entropy
-- this module produced only 'B32.masterKey' and 'B32.derivePath' can fail at
+18 -17
View File
@@ -9,8 +9,7 @@ import Control.Concurrent (threadDelay)
import Control.DeepSeq
import Control.Monad (unless, void)
import qualified Data.ByteString.Char8 as B
import Data.List (dropWhileEnd)
import Data.Maybe (fromJust, isJust)
import Data.Maybe (isNothing)
import Simplex.Messaging.Agent.Store.Postgres (closeDBStore, createDBStore)
import Simplex.Messaging.Agent.Store.Postgres.Common (DBOpts (..))
import qualified Simplex.Messaging.Agent.Store.Postgres.Migrations as Migrations
@@ -36,7 +35,7 @@ postgresSchemaDumpTest migrations testDBOpts@DBOpts {connstr, schema = testDBSch
getSchema srcSchemaPath `shouldReturn` savedSchema
testSchemaMigrations = do
let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) migrations
let noDownMigrations = takeWhile (\Migration {down} -> isNothing down) migrations
st <- createDBStore testDBOpts noDownMigrations (MigrationConfig MCYesUpDown Nothing) >>= \case
Right st -> pure st
Left e -> error $ show e
@@ -44,20 +43,22 @@ postgresSchemaDumpTest migrations testDBOpts@DBOpts {connstr, schema = testDBSch
closeDBStore st
whenM (doesFileExist testSchemaPath) $ removeFile testSchemaPath
where
testDownMigration st m = do
putStrLn $ "down migration " <> name m
let downMigr = fromJust $ toDownMigration m
schema <- getSchema testSchemaPath
Migrations.run st Nothing $ MTRUp [m]
schema' <- getSchema testSchemaPath
schema' `shouldNotBe` schema
Migrations.run st Nothing $ MTRDown [downMigr]
unless (name m `elem` skipComparisonForDownMigrations) $ do
schema'' <- getSchema testSchemaPath
schema'' `shouldBe` schema
Migrations.run st Nothing $ MTRUp [m]
schema''' <- getSchema testSchemaPath
schema''' `shouldBe` schema'
testDownMigration st m = case toDownMigration m of
-- a migration with no reverse step is applied, there is nothing to test
Nothing -> Migrations.run st Nothing $ MTRUp [m]
Just downMigr -> do
putStrLn $ "down migration " <> name m
schema <- getSchema testSchemaPath
Migrations.run st Nothing $ MTRUp [m]
schema' <- getSchema testSchemaPath
schema' `shouldNotBe` schema
Migrations.run st Nothing $ MTRDown [downMigr]
unless (name m `elem` skipComparisonForDownMigrations) $ do
schema'' <- getSchema testSchemaPath
schema'' `shouldBe` schema
Migrations.run st Nothing $ MTRUp [m]
schema''' <- getSchema testSchemaPath
schema''' `shouldBe` schema'
getSchema :: FilePath -> IO String
getSchema schemaPath = do
+20 -18
View File
@@ -11,9 +11,9 @@ import Control.Concurrent.STM
import Control.DeepSeq
import qualified Control.Exception as E
import Control.Monad (unless, void)
import Data.List (dropWhileEnd, sort)
import Data.List (sort)
import qualified Data.Map.Strict as M
import Data.Maybe (fromJust, isJust)
import Data.Maybe (isNothing)
import Data.Text (Text)
import qualified Data.Text as T
import qualified Data.Text.IO as T
@@ -79,28 +79,30 @@ testVerifyLintFKeyIndexes = withTmpFiles $ do
testSchemaMigrations :: IO ()
testSchemaMigrations = withTmpFiles $ do
let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations
let noDownMigrations = takeWhile (\Migration {down} -> isNothing down) Store.migrations
Right st <- createDBStore (DBOpts testDB chatDBFunctions "" False True TQOff) noDownMigrations (MigrationConfig MCError Nothing)
mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations
closeDBStore st
removeFile testDB
whenM (doesFileExist testSchema) $ removeFile testSchema
where
testDownMigration st m = do
putStrLn $ "down migration " <> name m
let downMigr = fromJust $ toDownMigration m
schema <- getSchema testDB testSchema
Migrations.run st Nothing True $ MTRUp [m]
schema' <- getSchema testDB testSchema
unless (name m `elem` skipComparisonForUpMigrations) $
schema' `shouldNotBe` schema
Migrations.run st Nothing True $ MTRDown [downMigr]
unless (name m `elem` skipComparisonForDownMigrations) $ do
schema'' <- getSchema testDB testSchema
schema'' `shouldBe` schema
Migrations.run st Nothing True $ MTRUp [m]
schema''' <- getSchema testDB testSchema
schema''' `shouldBe` schema'
testDownMigration st m = case toDownMigration m of
-- a migration with no reverse step is applied, there is nothing to test
Nothing -> Migrations.run st Nothing True $ MTRUp [m]
Just downMigr -> do
putStrLn $ "down migration " <> name m
schema <- getSchema testDB testSchema
Migrations.run st Nothing True $ MTRUp [m]
schema' <- getSchema testDB testSchema
unless (name m `elem` skipComparisonForUpMigrations) $
schema' `shouldNotBe` schema
Migrations.run st Nothing True $ MTRDown [downMigr]
unless (name m `elem` skipComparisonForDownMigrations) $ do
schema'' <- getSchema testDB testSchema
schema'' `shouldBe` schema
Migrations.run st Nothing True $ MTRUp [m]
schema''' <- getSchema testDB testSchema
schema''' `shouldBe` schema'
testVerifyStrict :: IO ()
testVerifyStrict = do
+124 -78
View File
@@ -10,10 +10,11 @@ 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 qualified Data.Text as T
import Simplex.Chat.Wallet (AccountIndex, WalletError (..), WalletSeed (..), accountSecret, deriveAccountKey, entropyFromMnemonic, renderAccountPath, seedMaster, seedMnemonic)
import Simplex.Chat.Wallet (AccountIndex, WalletError (..), accountSecret, deriveAccountKey, entropyFromMnemonic, renderAccountPath, seedMaster, seedMnemonic)
import qualified Simplex.Messaging.Crypto.BIP39 as B39
import qualified Simplex.Messaging.Crypto.Secp256k1 as S
import Simplex.Messaging.Eth.Address (addressFromPrivateKey)
@@ -32,27 +33,38 @@ testPhrase12 = B.unwords $ replicate 11 "abandon" <> ["about"]
testPhrase24 :: ByteString
testPhrase24 = B.unwords $ replicate 23 "abandon" <> ["art"]
seedFromPhrase :: ByteString -> WalletSeed
seedFromPhrase phrase =
WalletSeed {wsId = 1, wsEntropy = BA.convert . B39.mnemonicToEntropy . either error id $ B39.parseMnemonic phrase}
seedEntropy :: ByteString -> BA.ScrubbedBytes
seedEntropy phrase = BA.convert . B39.mnemonicToEntropy . either error id $ B39.parseMnemonic phrase
accountKey :: WalletSeed -> AccountIndex -> S.PrivateKey
accountKey seed n = either (error . show) id $ seedMaster seed >>= \m -> deriveAccountKey m n
accountKey :: BA.ScrubbedBytes -> AccountIndex -> S.PrivateKey
accountKey entropy n = either (error . show) id $ seedMaster entropy >>= \m -> deriveAccountKey m n
-- | The address a wallet reaches when the secret is imported as a private key.
addressFromSecret :: String -> String
addressFromSecret secret =
show . addressFromPrivateKey . either error id . S.mkPrivateKey . either error id $
BAE.convertFromBase BAE.Base16 (B.drop 2 $ B.pack secret)
-- | An @export account@ row: the index, the path, the address, the secret.
exportRow :: HasCallStack => String -> (String, String, String, String)
exportRow row = case words row of
[idx, path, address, secret] -> (idx, path, address, secret)
_ -> error $ "unexpected export row: " <> row
walletDerivationTests :: Spec
walletDerivationTests = do
Hspec.it "accounts are the accounts another wallet derives for the same phrase" $ do
let addrOf = show . addressFromPrivateKey . accountKey (seedFromPhrase testPhrase12)
let addrOf = show . addressFromPrivateKey . accountKey (seedEntropy testPhrase12)
-- Ledger Live accounts 1 and 2 for this phrase, the published values for it
addrOf 0 `shouldBe` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94"
addrOf 1 `shouldBe` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265"
Hspec.it "the exported secret is the one another wallet shows for that account" $ do
let k = accountKey (seedFromPhrase testPhrase12) 0
let k = accountKey (seedEntropy testPhrase12) 0
-- as a wallet shows it for m/44'/60'/0'/0/0 of this phrase
accountSecret k `shouldBe` "0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727"
Hspec.it "every account has its own address" $ do
let seed = seedFromPhrase testPhrase12
addrs = map (show . addressFromPrivateKey . accountKey seed) [0 .. 9]
let entropy = seedEntropy testPhrase12
addrs = map (show . addressFromPrivateKey . accountKey entropy) [0 .. 9]
length (nub addrs) `shouldBe` 10
Hspec.it "a secret whose first byte is zero keeps its 64 hex digits" $ do
let k = either error id . S.mkPrivateKey $ B.pack ('\0' : replicate 31 '\1')
@@ -63,74 +75,23 @@ walletDerivationTests = do
renderAccountPath 0 `shouldBe` "m/44'/60'/0'/0/0"
renderAccountPath 7 `shouldBe` "m/44'/60'/7'/0/0"
Hspec.it "round-trips the phrase it was imported from" $
seedMnemonic (seedFromPhrase testPhrase24) `shouldBe` Right (safeDecodeUtf8 testPhrase24)
seedMnemonic (seedEntropy testPhrase24) `shouldBe` Right (safeDecodeUtf8 testPhrase24)
Hspec.it "takes 24 words only, with a valid checksum" $ do
entropyFromMnemonic testPhrase24 `shouldSatisfy` isRight
entropyFromMnemonic testPhrase12 `shouldBe` Left WEBadMnemonic
entropyFromMnemonic (B.unwords $ replicate 24 "abandon") `shouldBe` Left WEBadMnemonic
testWalletHiddenProfile :: HasCallStack => TestParams -> IO ()
testWalletHiddenProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice ##> "/create user alisa"
showActiveUser alice "alisa"
alice ##> "/hide user my_password"
alice <## "current user alisa:"
alice <## "messages are hidden (use /tail to view)"
alice <## "profile is hidden"
alice ##> "/_wallet bind"
alice <## "wallet: a hidden profile cannot own an account"
testWalletExportNotMine :: HasCallStack => TestParams -> IO ()
testWalletExportNotMine ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice ##> "/_wallet bind"
alice <## "accounts: 0"
alice ##> "/create user alisa"
showActiveUser alice "alisa"
-- account 0 is the other profile's, and its key is not this profile's to take
alice ##> "/_wallet export account 0"
alice <## "wallet: another profile holds this account"
-- an account nobody holds is still derivable, which is what a scan needs
alice ##> "/_wallet export account 7"
row <- getTermLine alice
words row !! 1 `shouldBe` "m/44'/60'/7'/0/0"
testWalletIndexTooLarge :: HasCallStack => TestParams -> IO ()
testWalletIndexTooLarge ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
-- 2^31 is already a hardened component, so it would derive account 0's key
alice ##> "/_wallet address account=2147483648"
alice <## "wallet: account index is too large to harden"
alice ##> "/_wallet bind account=2147483648"
alice <## "wallet: account index is too large to harden"
alice ##> "/_wallet export account 2147483648"
alice <## "wallet: account index is too large to harden"
-- the largest index that can be hardened is usable, and the counter follows it
alice ##> "/_wallet bind account=2147483647"
alice <## "accounts: 2147483647"
alice ##> "/_wallet bind"
alice <## "wallet: account index is too large to harden"
-- | The address a wallet reaches when the secret is imported as a private key.
addressFromSecret :: String -> String
addressFromSecret secret =
show . addressFromPrivateKey . either error id . S.mkPrivateKey . either error id $
BAE.convertFromBase BAE.Base16 (B.drop 2 $ B.pack secret)
walletTests :: SpecWith TestParams
walletTests = do
it "creates no wallet until asked, and only one" testWalletCreate
it "binds the next free account, and re-binding one it holds changes nothing" testWalletBind
it "keeps each profile's accounts apart" testWalletAccountsPerProfile
it "taking the next account skips one already bound by index" testWalletBindByIndexThenNext
it "leaves a deleted profile's account for another profile to take" testWalletDeletedProfileAccount
it "derives an address without taking it" testWalletAddress
it "exports the master phrase and one account's secret" testWalletExport
it "will not take a new account on an imported phrase" testWalletImport
it "the wallet and the accounts come back after a restart" testWalletPersists
it "the wallet, the accounts and the counter come back after a restart" testWalletPersists
it "deletes the wallet, and one can be made again" testWalletDelete
it "a hidden profile is bound no account" testWalletHiddenProfile
it "will not export an account another profile holds" testWalletExportNotMine
@@ -145,6 +106,10 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice <## "no wallet on this device"
alice ##> "/_wallet export master"
alice <## "wallet: this device has no wallet"
alice ##> "/_wallet bind"
alice <## "wallet: this device has no wallet"
alice ##> "/_wallet delete"
alice <## "wallet: this device has no wallet"
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice ##> "/_wallet create new"
@@ -154,6 +119,11 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
-- a mistyped phrase says nothing about which word was wrong
alice ##> ("/_wallet create mnemonic=" <> B.unpack (B.unwords $ replicate 24 "abandon"))
alice <## "wallet: not a valid 24 word recovery phrase"
-- a phrase is taken as a backup card writes it, case and all
alice ##> ("/_wallet create mnemonic=" <> map toUpper (B.unpack testPhrase24))
alice <## "wallet, no accounts for this profile"
alice ##> "/_wallet export master"
alice <## B.unpack testPhrase24
testWalletBind :: HasCallStack => TestParams -> IO ()
testWalletBind ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
@@ -200,6 +170,23 @@ testWalletBindByIndexThenNext ps = withNewTestChat ps "alice" aliceProfile $ \al
alice ##> "/_wallet bind"
alice <## "accounts: 1, 2, 3, 4"
testWalletDeletedProfileAccount :: HasCallStack => TestParams -> IO ()
testWalletDeletedProfileAccount ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice ##> "/_wallet bind"
alice <## "accounts: 0"
alice ##> "/create user alisa"
showActiveUser alice "alisa"
-- deleting a profile does not take its account with it
alice ##> "/delete user alice"
alice <### ["ok", "completed deleting user"]
alice ##> "/_wallet"
alice <## "wallet, no accounts for this profile"
-- and another profile can take it, which is how a name outlives its profile
alice ##> "/_wallet bind account=0"
alice <## "accounts: 0"
testWalletAddress :: HasCallStack => TestParams -> IO ()
testWalletAddress ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
@@ -219,9 +206,6 @@ testWalletAddress ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice <## "bad chat command: Failed reading: empty"
alice ##> "/_wallet"
alice <## "wallet, no accounts for this profile"
-- an index BIP-32 cannot harden is refused rather than folded onto a low one
alice ##> "/_wallet address account=2147483648"
alice <## "wallet: account index is too large to harden"
testWalletExport :: HasCallStack => TestParams -> IO ()
testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
@@ -230,16 +214,23 @@ testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet export master"
alice <## B.unpack testPhrase24
alice ##> "/_wallet export account 0"
row <- getTermLine alice
case words row of
[idx, path, address, secret] -> do
idx `shouldBe` "0"
path `shouldBe` "m/44'/60'/0'/0/0"
-- m/44'/60'/0'/0/0 of the 24 word vector, pinned outside this
-- implementation, so a change of path fails here rather than shipping
address `shouldBe` "0xF278cF59F82eDcf871d630F28EcC8056f25C1cdb"
addressFromSecret secret `shouldBe` address
_ -> expectationFailure $ "unexpected export row: " <> row
(idx, path, address, secret) <- exportRow <$> getTermLine alice
idx `shouldBe` "0"
path `shouldBe` "m/44'/60'/0'/0/0"
-- m/44'/60'/0'/0/0 of the 24 word vector, pinned outside this implementation,
-- so a change of path fails here rather than shipping
address `shouldBe` "0xF278cF59F82eDcf871d630F28EcC8056f25C1cdb"
addressFromSecret secret `shouldBe` address
-- the index reaches the key, not only the path printed beside it
alice ##> "/_wallet export account 1"
(idx', path', address', _) <- exportRow <$> getTermLine alice
idx' `shouldBe` "1"
path' `shouldBe` "m/44'/60'/1'/0/0"
address' `shouldBe` show (addressFromPrivateKey $ accountKey (seedEntropy testPhrase24) 1)
-- and an address is read from the account the command names, not the counter
alice ##> "/_wallet address account=1"
addressRow <- words <$> getTermLine alice
addressRow `shouldBe` ["1", "m/44'/60'/1'/0/0", address']
testWalletImport :: HasCallStack => TestParams -> IO ()
testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
@@ -272,6 +263,9 @@ testWalletPersists ps = do
alice <## "accounts: 2"
alice ##> "/_wallet export master"
alice <## phrase
-- the counter came back too, so no account is handed out a second time
alice ##> "/_wallet bind"
alice <## "accounts: 2, 3"
testWalletDelete :: HasCallStack => TestParams -> IO ()
testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
@@ -283,6 +277,58 @@ testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice <## "ok"
alice ##> "/_wallet"
alice <## "no wallet on this device"
-- the accounts went with the entropy they were counted against
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
-- the new wallet holds no account and its counter starts over
alice ##> "/_wallet bind"
alice <## "accounts: 0"
testWalletHiddenProfile :: HasCallStack => TestParams -> IO ()
testWalletHiddenProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice ##> "/create user alisa"
showActiveUser alice "alisa"
alice ##> "/hide user my_password"
alice <## "current user alisa:"
alice <## "messages are hidden (use /tail to view)"
alice <## "profile is hidden"
alice ##> "/_wallet bind"
alice <## "wallet: a hidden profile cannot own an account"
testWalletExportNotMine :: HasCallStack => TestParams -> IO ()
testWalletExportNotMine ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
alice ##> "/_wallet bind"
alice <## "accounts: 0"
-- the profile's own account is its to export
alice ##> "/_wallet export account 0"
(_, path, _, _) <- exportRow <$> getTermLine alice
path `shouldBe` "m/44'/60'/0'/0/0"
alice ##> "/create user alisa"
showActiveUser alice "alisa"
-- account 0 is the other profile's, and its key is not this profile's to take
alice ##> "/_wallet export account 0"
alice <## "wallet: another profile holds this account"
-- an account nobody holds is still derivable, which is what a scan needs
alice ##> "/_wallet export account 7"
(_, path', _, _) <- exportRow <$> getTermLine alice
path' `shouldBe` "m/44'/60'/7'/0/0"
testWalletIndexTooLarge :: HasCallStack => TestParams -> IO ()
testWalletIndexTooLarge ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do
alice ##> "/_wallet create new"
alice <## "wallet, no accounts for this profile"
-- 2^31 is already a hardened component, so it would derive account 0's key
alice ##> "/_wallet address account=2147483648"
alice <## "wallet: account index is too large to harden"
alice ##> "/_wallet bind account=2147483648"
alice <## "wallet: account index is too large to harden"
alice ##> "/_wallet export account 2147483648"
alice <## "wallet: account index is too large to harden"
-- the largest index that can be hardened is usable, and the counter follows it
alice ##> "/_wallet bind account=2147483647"
alice <## "accounts: 2147483647"
alice ##> "/_wallet bind"
alice <## "wallet: account index is too large to harden"