mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-10-22 15:22:07 +00:00
add down migration for db and drop redundant masterkey field in db
This commit is contained in:
1 parent
f41a582b88
commit
4936e29a39
14 files changed
+92
-77
No files matched your search
@@ -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
|
||||
|
||||
@@ -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)))
|
||||
);
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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 =
|
||||
|
||||
@@ -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,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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user