From 4936e29a39296aeb1ecd11747b5b27be204b541f Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 30 Sep 2026 12:30:29 +0200 Subject: [PATCH] add down migration for db and drop redundant masterkey field in db --- docs/rfcs/2026-09-10-wallet-keys.md | 10 ++--- src/Simplex/Chat/Library/Commands.hs | 37 ++++++++++--------- src/Simplex/Chat/Store/Postgres/Migrations.hs | 2 +- .../Migrations/M20260924_wallet_seeds.hs | 12 +++++- .../Store/Postgres/Migrations/chat_schema.sql | 2 - src/Simplex/Chat/Store/SQLite/Migrations.hs | 2 +- .../Migrations/M20260924_wallet_seeds.hs | 12 +++++- .../SQLite/Migrations/chat_query_plans.txt | 4 +- .../Store/SQLite/Migrations/chat_schema.sql | 1 - src/Simplex/Chat/Store/Wallets.hs | 35 +++++++++--------- src/Simplex/Chat/Wallet.hs | 16 ++++---- tests/PostgresSchemaDump.hs | 13 ++++--- tests/SchemaDump.hs | 14 +++---- tests/WalletTests.hs | 9 ++--- 14 files changed, 92 insertions(+), 77 deletions(-) diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index ddd41f314e..f6ffbfcc41 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -13,7 +13,7 @@ Out of scope of this doc: buying a name, the names protocol, the registrar, sign 1. Every key the device uses is derivable again from the master phrase alone. 2. Giving away one account key gives away that account and nothing else, whatever else the recipient has. 3. No extended public key links two accounts. -4. Key material is generated or imported only by an explicit command, never at startup; reading the wallet row re-derives the master key to check it, and account keys are derived only by the command that uses them. +4. Key material is generated or imported only by an explicit command, never at startup, and keys are derived only by the command that uses them. 5. A hidden profile owns nothing on chain, so nothing on chain is linked to it. The master phrase derives every account and an account secret controls one account; no export covers anything in between, such as "this profile's accounts", because the profile is not an input to the derivation. @@ -124,7 +124,6 @@ What the scan finds is unbound, and the user attaches each account to a profile CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, entropy BLOB NOT NULL CHECK (length(entropy) IN (16, 20, 24, 28, 32)), - master BLOB NOT NULL CHECK (length(master) = 64), next_account_index INTEGER CHECK (next_account_index BETWEEN 0 AND 2147483648), -- null means not known yet single_seed INTEGER NOT NULL DEFAULT 1 ) STRICT; @@ -141,9 +140,9 @@ CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_a CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id); ``` -The master entropy, 32 bytes when generated and 16 to 32 bytes when imported, is stored with the BIP-32 master key it derives, the 32-byte private key followed by the 32-byte chain code. Reading the row recomputes the master key from the entropy and rejects a row where the two do not match, so a wallet read from the database always derives accounts. An account key is never stored, because the master key and an account index derive it whenever it is required. So `wallet_accounts` contains what derivation cannot produce: which account indexes are recorded on the device and which profile each belongs to. A row with no `user_id` is an account no profile holds, which is the result of deleting a chat profile and what a scan writes. +The master entropy, 32 bytes when generated and 16 to 32 bytes when imported, is the only key material stored. `create` derives the first account before storing the entropy, so stored entropy is known to derive keys. Neither the master key nor an account key is stored, because the entropy and an account index derive them whenever they are required. So `wallet_accounts` contains what derivation cannot produce: which account indexes are recorded on the device and which profile each belongs to. A row with no `user_id` is an account no profile holds, which is the result of deleting a chat profile and what a scan writes. -`users` is not changed: the mapping is stored in the account row, and the index on `user_id` is not unique, because a profile can hold any number of accounts. One seed per device is enforced by `single_seed` and the unique index on it, which a later change removes with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop an inline constraint without rebuilding the table. Deleting the master deletes its account rows, because an account index without its entropy derives nothing. The migration has no down migration, because a down migration would delete the master entropy, which may have no other copy. A down migration runs when an older app opens a newer database and the user confirms "Downgrade and open chat", and the backup made then is overwritten by the next upgrade. Without a down migration the older app reports that the database is newer than the app, and changes nothing. +`users` is not changed: the mapping is stored in the account row, and the index on `user_id` is not unique, because a profile can hold any number of accounts. One seed per device is enforced by `single_seed` and the unique index on it, which a later change removes with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop an inline constraint without rebuilding the table. Deleting the master deletes its account rows, because an account index without its entropy derives nothing. The down migration drops both tables, which deletes the master entropy; it runs only when the user confirms "Downgrade and open chat" in an older app. A null `account_index` marks an account whose key was imported rather than derived. The master phrase does not recover such an account, and the schema must not suggest that it does. Importing one is not implemented here; the column is nullable now so that a row written later is read correctly. That feature also requires storage for the imported secret and an optional link to a seed, because such an account belongs to no seed and must not be deleted with one; on SQLite, making `wallet_seed_id` nullable rebuilds the table. @@ -154,7 +153,7 @@ 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 gives that vendor every account on the device at once, across every profile. That is what an account for each name otherwise prevents. - **Whoever answers the recovery scan.** Receives every address the scan derives from the phrase, in one sequence of requests, so it can link every account on the device, across profiles, and recognise addresses that own nothing yet, which is where future accounts will be. `address` derives an address for any index directly from the master, so a caller can enumerate hidden profiles' addresses too. This is the largest privacy cost of the design. - **A paired device.** Wallet commands are allowed from a paired device like other chat commands: `export master` returns the whole wallet, `create mnemonic=` on a device that has no seed imports a phrase that the paired device sends, and `delete` deletes the master entropy, which may have no other copy. -- **Someone reading the logs.** The core logs no command and no response, so a phrase passed to `create` is not written to any log. On Postgres the client inlines parameters into the statement text, so server-side statement logging records the inserted entropy and master key. +- **Someone reading the logs.** The core logs no command and no response, so a phrase passed to `create` is not written to any log. On Postgres the client inlines parameters into the statement text, so server-side statement logging records the inserted entropy. - **A page open in the user's browser.** The websocket server in `apps/simplex-chat/Server.hs`, which runs only with `--chat-server-port` and listens on 127.0.0.1, accepts any local connection, requires no token and checks no `Origin`, and websockets are not bound by the same origin policy, so any page loaded while that server runs can send `export master` and read the response. It also prints every command it receives, that phrase included. Both are properties of that server, which this change makes a more valuable target, and closing them requires changes to that server rather than to the wallet. ## Known limits @@ -176,7 +175,6 @@ 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/M20260924_wallet_seeds.hs` and the corresponding Postgres migration. - `tests/WalletTests.hs`. -- `tests/SchemaDump.hs` and `tests/PostgresSchemaDump.hs`, which selected the migrations to test as every migration after the last one without a down migration, and now select every migration from the first one that has a down migration, applying any that has none. - Derivation uses the `BIP32`, `BIP39`, `Secp256k1` and `Eth.Address` modules from simplexmq (simplex-chat/simplexmq#1843) and adds no dependency to this package. ## What is verified diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 32f7821fc3..dcd56ed039 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -65,7 +65,7 @@ import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeIss import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Badges.Service (BadgeBalance (..), BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), BadgeStatement (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..), currentBadgeServiceVersion) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) -import Simplex.Chat.Wallet (AccountKey, WalletAddress, WalletError (..), WalletInfo (..), accountSecret, deriveAccount, entropyFromMnemonic, masterMnemonic, newWalletMaster) +import Simplex.Chat.Wallet (AccountKey, WalletAddress, WalletError (..), WalletInfo (..), accountSecret, deriveAccount, entropyFromMnemonic, masterMnemonic, newEntropy) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -116,7 +116,7 @@ import Simplex.Messaging.Agent.Store.Interface (getCurrentMigrations) import Simplex.Messaging.Client (NetworkConfig (..), NetworkRequestMode (..), NetworkTimeout (..), SMPWebPortServers (..), SocksMode (SMAlways), pattern NRMInteractive, textToHostMode) import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.ShortLink as SL -import Simplex.Messaging.Crypto.BIP32 (WalletMaster, mkWalletMaster) +import Simplex.Messaging.Crypto.BIP39 (WalletEntropy) import Simplex.Messaging.Crypto.BIP44 (AccountIndex, mkAccountIndex) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -1507,25 +1507,28 @@ processChatCommand cxt nm = \case wallet_ <- withFastStore getWallet when (isJust wallet_) $ throwWalletError WEMasterExists -- the counter starts at 1 for a generated seed, leaving account 0 to other wallets, and is unknown for an imported one - (master, nextAccount) <- case mnemonic_ of - Nothing -> (,Just 1) <$> (liftDerivation . newWalletMaster =<< asks random) - Just phrase -> (,Nothing) <$> (liftDerivation . pure . mkWalletMaster =<< liftWallet (entropyFromMnemonic phrase)) - created <- withFastStore' $ \db -> createWallet db master nextAccount + (entropy, nextAccount) <- case mnemonic_ of + Nothing -> (,Just 1) <$> (liftIO . newEntropy =<< asks random) + Just phrase -> (,Nothing) <$> liftWallet (entropyFromMnemonic phrase) + -- derive the first account before storing, so stored entropy derives keys + firstAccount <- liftEitherWith (ChatError . CEInternalError) $ mkAccountIndex $ fromMaybe 0 nextAccount + void $ walletAccount entropy firstAccount + created <- withFastStore' $ \db -> createWallet db entropy nextAccount unless created $ throwWalletError WEMasterExists pure $ CRWallet user (Just $ WalletInfo [] nextAccount) APIBindWalletAccount userId accountIdx_ -> withUserId userId $ \user@User {viewPwdHash} -> do when (isJust viewPwdHash) $ throwWalletError WEHiddenProfile - (master, n) <- withWalletStore $ \db -> bindAccount db userId accountIdx_ - CRWalletAddress user . snd <$> walletAccount master n + (entropy, n) <- withWalletStore $ \db -> bindAccount db userId accountIdx_ + CRWalletAddress user . snd <$> walletAccount entropy n APIGetWalletAddress accountIdx_ -> withUser $ \user -> do - (master, n) <- withWalletStore (`resolveAccount` accountIdx_) - CRWalletAddress user . snd <$> walletAccount master n + (entropy, n) <- withWalletStore (`resolveAccount` accountIdx_) + CRWalletAddress user . snd <$> walletAccount entropy n APIExportWalletMnemonic -> withUser $ \user -> do - Wallet {walletMaster} <- withFastStore getWallet >>= maybe (throwWalletError WENoMaster) pure - pure $ CRWalletMnemonic user (masterMnemonic walletMaster) + Wallet {entropy} <- withFastStore getWallet >>= maybe (throwWalletError WENoMaster) pure + pure $ CRWalletMnemonic user (masterMnemonic entropy) APIExportWalletAccount userId n -> withUserId userId $ \user -> do - master <- withWalletStore $ \db -> heldAccount db userId n - (k, a) <- walletAccount master n + entropy <- withWalletStore $ \db -> heldAccount db userId n + (k, a) <- walletAccount entropy n pure $ CRWalletAccountSecret user a (accountSecret k) APIDeleteWallet -> withUser_ $ do deleted <- withFastStore' deleteWallet @@ -6034,10 +6037,10 @@ liftWallet = liftEitherWith (ChatError . CEWallet) withWalletStore :: (DB.Connection -> ExceptT StoreError IO (Either WalletError a)) -> CM a withWalletStore action = liftWallet =<< withFastStore action -walletAccount :: WalletMaster -> AccountIndex -> CM (AccountKey, WalletAddress) -walletAccount master n = do +walletAccount :: WalletEntropy -> AccountIndex -> CM (AccountKey, WalletAddress) +walletAccount entropy n = do g <- asks random - liftDerivation $ deriveAccount g master n + liftDerivation $ deriveAccount g entropy n liftDerivation :: IO (Either String a) -> CM a liftDerivation = liftError' (ChatError . CEInternalError) diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 60ae8f999e..d479778034 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -107,7 +107,7 @@ schemaMigrations = ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), - ("20260924_wallet_seeds", m20260924_wallet_seeds, Nothing) + ("20260924_wallet_seeds", m20260924_wallet_seeds, Just down_m20260924_wallet_seeds) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs index 9cff8f3d9c..4e294cd2cf 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs @@ -12,7 +12,6 @@ m20260924_wallet_seeds = CREATE TABLE wallet_seeds ( wallet_seed_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, entropy BYTEA NOT NULL CHECK (length(entropy) IN (16, 20, 24, 28, 32)), - master BYTEA NOT NULL CHECK (length(master) = 64), next_account_index BIGINT CHECK (next_account_index BETWEEN 0 AND 2147483648), single_seed SMALLINT NOT NULL DEFAULT 1 ); @@ -29,4 +28,13 @@ CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_a CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id); |] --- no down migration, see the SQLite migration +down_m20260924_wallet_seeds :: Text +down_m20260924_wallet_seeds = + [r| +DROP INDEX idx_wallet_accounts_user_id; +DROP INDEX idx_wallet_accounts_wallet_seed_id_account_index; +DROP INDEX idx_wallet_seeds_single_seed; + +DROP TABLE wallet_accounts; +DROP TABLE wallet_seeds; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index b31b19b1af..d3d1aaa55e 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1754,11 +1754,9 @@ ALTER TABLE test_chat_schema.wallet_accounts ALTER COLUMN wallet_account_id ADD CREATE TABLE test_chat_schema.wallet_seeds ( wallet_seed_id bigint NOT NULL, entropy bytea NOT NULL, - master bytea NOT NULL, next_account_index bigint, single_seed smallint DEFAULT 1 NOT NULL, CONSTRAINT wallet_seeds_entropy_check CHECK ((length(entropy) = ANY (ARRAY[16, 20, 24, 28, 32]))), - CONSTRAINT wallet_seeds_master_check CHECK ((length(master) = 64)), CONSTRAINT wallet_seeds_next_account_index_check CHECK (((next_account_index >= 0) AND (next_account_index <= '2147483648'::bigint))) ); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index 9e6ff903a6..d92edb2fb6 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -353,7 +353,7 @@ schemaMigrations = ("20260915_user_badges", m20260915_user_badges, Just down_m20260915_user_badges), ("20260918_badge_issue_errors", m20260918_badge_issue_errors, Just down_m20260918_badge_issue_errors), ("20260923_preferences_json", m20260923_preferences_json, Just down_m20260923_preferences_json), - ("20260924_wallet_seeds", m20260924_wallet_seeds, Nothing) + ("20260924_wallet_seeds", m20260924_wallet_seeds, Just down_m20260924_wallet_seeds) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs index bb389633bd..09fc630e56 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs @@ -11,7 +11,6 @@ m20260924_wallet_seeds = CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, entropy BLOB NOT NULL CHECK (length(entropy) IN (16, 20, 24, 28, 32)), - master BLOB NOT NULL CHECK (length(master) = 64), next_account_index INTEGER CHECK (next_account_index BETWEEN 0 AND 2147483648), single_seed INTEGER NOT NULL DEFAULT 1 ) STRICT; @@ -28,4 +27,13 @@ CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_a CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id); |] --- No down migration: it would delete the master entropy, which may have no other copy. +down_m20260924_wallet_seeds :: Query +down_m20260924_wallet_seeds = + [sql| +DROP INDEX idx_wallet_accounts_user_id; +DROP INDEX idx_wallet_accounts_wallet_seed_id_account_index; +DROP INDEX idx_wallet_seeds_single_seed; + +DROP TABLE wallet_accounts; +DROP TABLE wallet_seeds; +|] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index a2a70276d7..fa121c4719 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -2049,7 +2049,7 @@ Query: Plan: Query: - INSERT INTO wallet_seeds (entropy, master, next_account_index) VALUES (?, ?, ?) + INSERT INTO wallet_seeds (entropy, next_account_index) VALUES (?, ?) ON CONFLICT (single_seed) DO NOTHING RETURNING wallet_seed_id @@ -7820,7 +7820,7 @@ Query: SELECT via_contact_uri, via_contact_uri_hash FROM connections WHERE conne Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) -Query: SELECT wallet_seed_id, entropy, master, next_account_index FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1 +Query: SELECT wallet_seed_id, entropy, next_account_index FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1 Plan: SCAN wallet_seeds diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index b3f1c1a4f8..42c567ed3d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -984,7 +984,6 @@ CREATE TABLE badge_code_redemptions( CREATE TABLE wallet_seeds( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, entropy BLOB NOT NULL CHECK(length(entropy) IN(16, 20, 24, 28, 32)), - master BLOB NOT NULL CHECK(length(master) = 64), next_account_index INTEGER CHECK(next_account_index BETWEEN 0 AND 2147483648), single_seed INTEGER NOT NULL DEFAULT 1 ) STRICT; diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index 074cfc0e8d..0607f95045 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -30,8 +30,7 @@ import Simplex.Chat.Wallet (WalletError (..)) import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.DB as DB -import Simplex.Messaging.Crypto.BIP32 (WalletMaster, masterBytes, masterEntropy, parseWalletMaster) -import Simplex.Messaging.Crypto.BIP39 (unEntropy) +import Simplex.Messaging.Crypto.BIP39 (WalletEntropy, mkEntropy, unEntropy) import Simplex.Messaging.Crypto.BIP44 (AccountIndex, mkAccountIndex, unAccountIndex) import Simplex.Messaging.Util (liftEitherWith) @@ -47,37 +46,37 @@ type SeedId = Int64 data Wallet = Wallet { walletId :: SeedId, - walletMaster :: WalletMaster, + entropy :: WalletEntropy, nextAccountIndex :: Maybe Word32 } getWallet :: DB.Connection -> ExceptT StoreError IO (Maybe Wallet) getWallet db = - liftIO (maybeFirstRow id $ DB.query_ db "SELECT wallet_seed_id, entropy, master, next_account_index FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1") + liftIO (maybeFirstRow id $ DB.query_ db "SELECT wallet_seed_id, entropy, next_account_index FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1") >>= mapM toWallet where - toWallet :: (SeedId, ByteString, ByteString, Maybe Word32) -> ExceptT StoreError IO Wallet - toWallet (walletId, entropy, master, nextAccountIndex) = - liftEitherWith SEInternalError $ (\walletMaster -> Wallet {walletId, walletMaster, nextAccountIndex}) <$> parseWalletMaster (BA.convert entropy) (BA.convert master) + toWallet :: (SeedId, ByteString, Maybe Word32) -> ExceptT StoreError IO Wallet + toWallet (walletId, entBytes, nextAccountIndex) = + liftEitherWith SEInternalError $ (\entropy -> Wallet {walletId, entropy, nextAccountIndex}) <$> mkEntropy (BA.convert entBytes) -createWallet :: DB.Connection -> WalletMaster -> Maybe Word32 -> IO Bool -createWallet db master nextAccount = +createWallet :: DB.Connection -> WalletEntropy -> Maybe Word32 -> IO Bool +createWallet db entropy nextAccount = rowReturned $ DB.query db [sql| - INSERT INTO wallet_seeds (entropy, master, next_account_index) VALUES (?, ?, ?) + INSERT INTO wallet_seeds (entropy, next_account_index) VALUES (?, ?) ON CONFLICT (single_seed) DO NOTHING RETURNING wallet_seed_id |] - (DB.Binary (BA.convert (unEntropy $ masterEntropy master) :: ByteString), DB.Binary (BA.convert (masterBytes master) :: ByteString), nextAccount) + (DB.Binary (BA.convert (unEntropy entropy) :: ByteString), nextAccount) deleteWallet :: DB.Connection -> IO Bool deleteWallet db = rowReturned $ DB.query_ db "DELETE FROM wallet_seeds RETURNING wallet_seed_id" -resolveAccount :: DB.Connection -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (WalletMaster, AccountIndex)) -resolveAccount db accountIdx_ = fmap (first walletMaster) <$> resolveAccount_ db accountIdx_ +resolveAccount :: DB.Connection -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (WalletEntropy, AccountIndex)) +resolveAccount db accountIdx_ = fmap (first entropy) <$> resolveAccount_ db accountIdx_ resolveAccount_ :: DB.Connection -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (Wallet, AccountIndex)) resolveAccount_ db accountIdx_ = @@ -105,16 +104,16 @@ accountUser db sId n = maybeFirstRow fromOnly $ DB.query db "SELECT user_id FROM wallet_accounts WHERE wallet_seed_id = ? AND account_index = ?" (sId, n) -bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (WalletMaster, AccountIndex)) +bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (WalletEntropy, AccountIndex)) bindAccount db userId accountIdx_ = resolveAccount_ db accountIdx_ >>= either (pure . Left) (liftIO . bind) where - bind (Wallet {walletId, walletMaster}, n) = do + bind (Wallet {walletId, entropy}, n) = do held <- accountUser db walletId n >>= \case Just (Just heldBy) -> pure $ heldBy == userId Just Nothing -> setAccountUser db userId walletId n Nothing -> True <$ insertAccount db userId walletId n - if held then Right (walletMaster, n) <$ raiseNextAccount db walletId n else pure $ Left WEAccountBound + if held then Right (entropy, n) <$ raiseNextAccount db walletId n else pure $ Left WEAccountBound setAccountUser :: DB.Connection -> UserId -> SeedId -> AccountIndex -> IO Bool setAccountUser db userId sId n = @@ -128,11 +127,11 @@ setAccountUser db userId sId n = |] (userId, sId, n) -heldAccount :: DB.Connection -> UserId -> AccountIndex -> ExceptT StoreError IO (Either WalletError WalletMaster) +heldAccount :: DB.Connection -> UserId -> AccountIndex -> ExceptT StoreError IO (Either WalletError WalletEntropy) heldAccount db userId n = getWallet db >>= \case Nothing -> pure $ Left WENoMaster - Just Wallet {walletId, walletMaster} -> liftIO $ (\held -> if held == Just (Just userId) then Right walletMaster else Left WEAccountNotHeld) <$> accountUser db walletId n + Just Wallet {walletId, entropy} -> liftIO $ (\held -> if held == Just (Just userId) then Right entropy else Left WEAccountNotHeld) <$> accountUser db walletId n raiseNextAccount :: DB.Connection -> SeedId -> AccountIndex -> IO () raiseNextAccount db sId n = diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index 4dfb37db97..af30575c57 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -6,7 +6,7 @@ module Simplex.Chat.Wallet WalletAddress (..), WalletInfo (..), WalletError (..), - newWalletMaster, + newEntropy, entropyFromMnemonic, masterMnemonic, deriveAccount, @@ -58,17 +58,19 @@ data WalletError masterStrength :: B39.EntropyStrength masterStrength = B39.ES256 -newWalletMaster :: TVar ChaChaDRG -> IO (Either String B32.WalletMaster) -newWalletMaster g = B32.mkWalletMaster <$> atomically (B39.randomEntropy masterStrength g) +newEntropy :: TVar ChaChaDRG -> IO B39.WalletEntropy +newEntropy = atomically . B39.randomEntropy masterStrength entropyFromMnemonic :: Text -> Either WalletError B39.WalletEntropy entropyFromMnemonic = first (const WEBadMnemonic) . B39.parsePhrase -masterMnemonic :: B32.WalletMaster -> Text -masterMnemonic = decodeLatin1 . B39.entropyPhrase . B32.masterEntropy +masterMnemonic :: B39.WalletEntropy -> Text +masterMnemonic = decodeLatin1 . B39.entropyPhrase -deriveAccount :: TVar ChaChaDRG -> B32.WalletMaster -> AccountIndex -> IO (Either String (AccountKey, WalletAddress)) -deriveAccount g master n = fmap account <$> deriveAddress g (B32.walletMasterKey master) path +deriveAccount :: TVar ChaChaDRG -> B39.WalletEntropy -> AccountIndex -> IO (Either String (AccountKey, WalletAddress)) +deriveAccount g entropy n = case B32.masterKey (B39.entropySeed entropy "") of + Left e -> pure $ Left e + Right master -> fmap account <$> deriveAddress g master path where path = bip44Path Ethereum n account (xk, a) = (B32.xkKey xk, WalletAddress {accountIndex = n, keyPath = decodeLatin1 $ B32.renderPath path, address = a}) diff --git a/tests/PostgresSchemaDump.hs b/tests/PostgresSchemaDump.hs index 6809955f57..b36b7faeaf 100644 --- a/tests/PostgresSchemaDump.hs +++ b/tests/PostgresSchemaDump.hs @@ -7,9 +7,10 @@ module PostgresSchemaDump (postgresSchemaDumpTest) where import ChatTests.Utils hiding (it) import Control.Concurrent (threadDelay) import Control.DeepSeq -import Control.Monad (forM_, unless, void) +import Control.Monad (unless, void) import qualified Data.ByteString.Char8 as B -import Data.Maybe (isNothing) +import Data.List (dropWhileEnd) +import Data.Maybe (fromJust, isJust) 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 @@ -35,17 +36,17 @@ postgresSchemaDumpTest migrations testDBOpts@DBOpts {connstr, schema = testDBSch getSchema srcSchemaPath `shouldReturn` savedSchema testSchemaMigrations = do - let noDownMigrations = takeWhile (\Migration {down} -> isNothing down) migrations + let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) migrations st <- createDBStore testDBOpts noDownMigrations (MigrationConfig MCYesUpDown Nothing) >>= \case Right st -> pure st Left e -> error $ show e - forM_ (drop (length noDownMigrations) migrations) $ \m -> - maybe (Migrations.run st Nothing $ MTRUp [m]) (testDownMigration st m) (toDownMigration m) + mapM_ (testDownMigration st) $ drop (length noDownMigrations) migrations closeDBStore st whenM (doesFileExist testSchemaPath) $ removeFile testSchemaPath where - testDownMigration st m downMigr = do + 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 diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index d16a14af2c..783f6587bf 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -10,10 +10,10 @@ import ChatTests.DBUtils import Control.Concurrent.STM import Control.DeepSeq import qualified Control.Exception as E -import Control.Monad (forM_, unless, void) -import Data.List (sort) +import Control.Monad (unless, void) +import Data.List (dropWhileEnd, sort) import qualified Data.Map.Strict as M -import Data.Maybe (isNothing) +import Data.Maybe (fromJust, isJust) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.IO as T @@ -79,16 +79,16 @@ testVerifyLintFKeyIndexes = withTmpFiles $ do testSchemaMigrations :: IO () testSchemaMigrations = withTmpFiles $ do - let noDownMigrations = takeWhile (\Migration {down} -> isNothing down) Store.migrations + let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations Right st <- createDBStore (DBOpts testDB chatDBFunctions "" False True TQOff) noDownMigrations (MigrationConfig MCError Nothing) - forM_ (drop (length noDownMigrations) Store.migrations) $ \m -> - maybe (Migrations.run st Nothing True $ MTRUp [m]) (testDownMigration st m) (toDownMigration m) + mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations closeDBStore st removeFile testDB whenM (doesFileExist testSchema) $ removeFile testSchema where - testDownMigration st m downMigr = do + 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 diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 6f3719fde8..b256025849 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -16,7 +16,6 @@ import qualified Data.Text as T import Data.Word (Word32) import Simplex.Chat.Wallet (AccountKey, WalletAddress (..), WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, masterMnemonic) import qualified Simplex.Messaging.Crypto as C -import qualified Simplex.Messaging.Crypto.BIP32 as B32 import qualified Simplex.Messaging.Crypto.BIP39 as B39 import Simplex.Messaging.Crypto.BIP44 (mkAccountIndex) import qualified Simplex.Messaging.Crypto.Secp256k1 as S @@ -31,13 +30,13 @@ testPhrase12 = T.unwords $ replicate 11 "abandon" <> ["about"] testPhrase24 :: Text testPhrase24 = T.unwords $ replicate 23 "abandon" <> ["art"] -walletMaster :: Text -> B32.WalletMaster -walletMaster phrase = either error id $ B32.mkWalletMaster (either error id $ B39.parsePhrase phrase) +walletEntropy :: Text -> B39.WalletEntropy +walletEntropy = either error id . B39.parsePhrase walletAccount :: Text -> Word32 -> IO (AccountKey, WalletAddress) walletAccount phrase n = do g <- C.newRandom - either error id <$> deriveAccount g (walletMaster phrase) (either error id $ mkAccountIndex n) + either error id <$> deriveAccount g (walletEntropy phrase) (either error id $ mkAccountIndex n) addressFromSecret :: String -> IO String addressFromSecret secret = do @@ -74,7 +73,7 @@ walletDerivationTests = do (keyPath . snd <$> walletAccount testPhrase12 0) `shouldReturn` "m/44'/60'/0'/0/0" (keyPath . snd <$> walletAccount testPhrase12 7) `shouldReturn` "m/44'/60'/7'/0/0" Hspec.it "round-trips the phrase it was imported from" $ - masterMnemonic (walletMaster testPhrase24) `shouldBe` testPhrase24 + masterMnemonic (walletEntropy testPhrase24) `shouldBe` testPhrase24 Hspec.it "accepts a phrase of any BIP-39 length with a valid checksum" $ do entropyFromMnemonic testPhrase24 `shouldSatisfy` isRight entropyFromMnemonic testPhrase12 `shouldSatisfy` isRight