mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-27 15:48:54 +00:00
/goal iteration 5 - unconfirmed
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user