add down migration for db and drop redundant masterkey field in db

This commit is contained in:
Alain Brenzikofer committed 2026-09-30 12:30:29 +02:00
1 parent f41a582b88
commit 4936e29a39
14 files changed
+92 -77

No files matched your search

+4 -6
View File
@@ -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
+20 -17
View File
@@ -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)
@@ -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
@@ -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;
|]
@@ -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)))
);
+1 -1
View File
@@ -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
@@ -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;
|]
@@ -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
@@ -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;
+17 -18
View File
@@ -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 =
+9 -7
View File
@@ -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})
+7 -6
View File
@@ -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
+7 -7
View File
@@ -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
+4 -5
View File
@@ -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