diff --git a/cabal.project b/cabal.project index abb3a7d37e..95fbeeeb88 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 714803f7a00f51379621b131ffa561d7b61c26d2 + tag: 256ef554486e5de67401c3d9d0e5ce2c35402247 source-repository-package type: git diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 0ab352b65f..0e55941c8e 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -2,36 +2,36 @@ ## Problem -The client has no static keys of its own which could be used for blockchain use cases. It needs them at addresses it can derive again after a restart, after a database restore, or on a new device, or whatever those addresses hold is lost with the device. +The client has no static keys of its own for blockchain use cases. It requires keys at addresses it can derive again after a restart, after a database restore, or on a new device; otherwise whatever those addresses hold is lost with the device. -The first consumer is [public namespaces](./2026-05-21-public-namespaces.md): a name is a record on a public blockchain saying which address owns it, and every name gets an account of its own, so one chat profile needs many accounts. This is not a names wallet only, though. An account is an ordinary Ethereum account, and [stealth transfers](https://github.com/simplex-chat/simplex-chat/pull/7519) will take accounts the same way. A device also holds several chat profiles, which are meant to stay isolated, and the one phrase the user writes down has to recover all of them. +The first consumer is [public namespaces](./2026-05-21-public-namespaces.md): a name is a record on a public blockchain that contains the owner's address, and every name is owned by an account of its own, so one chat profile requires many accounts. The wallet is not specific to names. An account is an ordinary Ethereum account, and [stealth transfers](https://github.com/simplex-chat/simplex-chat/pull/7519) will use accounts in the same way. A device also contains several chat profiles, which are meant to stay isolated, and the one phrase the user writes down has to recover all of them. -Out of scope of this doc: buying a name, the names protocol, the registrar, signing, the scanning of the chain and the command that records what it finds, importing an account key that the master does not derive, more than one master, and stealth keys. Buying is not implemented yet either, so the keys land first and can be reviewed on their own. +Out of scope of this doc: buying a name, the names protocol, the registrar, signing, the chain scan and the command that records its results, importing an account key that the master does not derive, more than one master, and stealth keys. Buying is not implemented yet either, so key derivation is merged first and can be reviewed on its own. ## Security objectives 1. Every key the device uses is derivable again from the master phrase alone. -2. Handing over one account key hands over that account and nothing else, whatever else the holder has. +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 made and handed out only when asked for, never at startup and never as a side effect of reading. -5. A hidden profile owns nothing on chain, so nothing on chain is tied to it. +4. Key material is generated or derived only by an explicit command, never at startup and never as a side effect of a read. +5. A hidden profile owns nothing on chain, so nothing on chain is linked to it. -There is no unit between the two: the master phrase is every account, and an account secret is one account. Nothing hands over "this profile's accounts", because nothing in the derivation knows about profiles. +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. ## Design ### Accounts -A device has one piece of [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) entropy, the **master**, made as 24 words. Nothing signs with it. Every key is a [BIP-44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki) account under it, at a hardened account index, which is how Ledger Live lays out an Ethereum wallet. +A device has one piece of [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) entropy, the **master**, generated as 24 words. Nothing signs with it. Every key is a [BIP-44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki) account under it, at a hardened account index, which is how Ledger Live lays out an Ethereum wallet. -Each thing the device owns on chain is given an **account index**, and the key there is the **account key**. Names are the first use: one name, one account, and that account's address is what the name record names as its owner. Which chat profile an account belongs to is a mapping in the database. No path mentions a profile, so nothing about a profile reaches the derivation and the two can be rearranged without a key moving. +Each thing the device owns on chain is assigned an **account index**, and the key at that index is the **account key**. Names are the first use: one name, one account, and that account's address is the owner address in the name record. Which chat profile an account belongs to is a mapping in the database. No path contains a profile, so no profile data is an input to the derivation, and the mapping can be changed without changing any key. ``` master seed, 24 words the only thing to back up └── m/44'/60'/n'/0/0 account n, n >= 0 ``` -An account index is BIP-44's own account level, the level [Ledger Live](https://github.com/LedgerHQ/ledger-live-common/blob/HEAD/docs/derivation.md) varies and also calls an account, numbering from one where its Account 1 is index 0. So the master phrase given to another wallet reaches the same addresses and the two tools use the word for the same thing. That wallet finds account 0 and stops, because BIP-44 says to stop at the first account with no transaction history and an account that only owns a name never transacts; above it the user has to enter the path. +An account index is BIP-44's own account level, the level [Ledger Live](https://github.com/LedgerHQ/ledger-live-common/blob/HEAD/docs/derivation.md) varies and also calls an account, numbering from one where its Account 1 is index 0. So the master phrase imported into another wallet derives the same addresses, and the two tools use the word for the same thing. That wallet discovers account 0 and stops, because BIP-44 specifies that discovery stops at the first account with no transaction history and an account that only owns a name has none; above it the user has to enter the path. The tests pin the derivation against the standard `abandon ... about` test mnemonic, which is 12 words, with an empty BIP-39 passphrase. Addresses are in [EIP-55](https://eips.ethereum.org/EIPS/eip-55) mixed case. @@ -42,81 +42,81 @@ account 1 m/44'/60'/1'/0/0 0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265 ### Why an account for each name -The alternative is one account holding several names. A name's owner address is public, so that address would link them, and that is as true inside one profile as across two. An account for each name leaves no such link, and the same holds for anything else an account comes to own. +The alternative is one account owning several names. A name's owner address is public, so that address would link them, and that is as true inside one profile as across two. An account for each name creates no such link, and the same holds for anything else an account comes to own. ### Why the account level is hardened -The alternative is BIP-44's ordinary address level, `m/44'/60'/0'/0/n`, which is what MetaMask enumerates and is therefore the friendlier path. It is not hardened, and [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) has a trap there: the extended public key above a key, together with that one key's private half, yields the parent private key and from it every sibling. Handing out one account key is one half, and any wallet that enumerates accounts produces the other. The two steps below an account are not hardened, so those halves do reach that account's own key, but nothing else is derived under an account and the account level itself is hardened, so they stop there and reach no other account. That is objectives 2 and 3, and it is worth the loss of MetaMask's default path. +The alternative is BIP-44's ordinary address level, `m/44'/60'/0'/0/n`, which is what MetaMask enumerates and is therefore the friendlier path. It is not hardened, and [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) has a known weakness there: the extended public key of a parent, together with one non-hardened child's private key, yields the parent private key and from it every sibling. An exported account key is one half, and any wallet that enumerates accounts produces the other. The two levels below an account are not hardened, so the two halves together reveal that account's key; nothing else is derived under an account and the account level itself is hardened, so they reveal no other account's key. This satisfies objectives 2 and 3, and it is worth the loss of MetaMask's default path. ### Why 24 words -Entropy cannot be added to a seed afterwards and what a seed is used for can grow, so the master is 24 words, 256 bits, which is a deliberate choice rather than a default. +Entropy cannot be added to a seed afterwards and the uses of a seed can grow, so the master is 24 words, 256 bits. ## Profiles and accounts -An account is bound to at most one chat profile, and a profile to any number of accounts, because a profile can own any number of names. Only one of those names points at the profile as its SimpleX domain name, but which one is the names layer's record, not the wallet's: the wallet says only which profile an account belongs to. +An account is bound to at most one chat profile, and a profile to any number of accounts, because a profile can own any number of names. Only one of those names points at the profile as its SimpleX domain name, but which one is recorded by the names layer, not by the wallet: the wallet records only which profile an account belongs to. -Accounts are handed out in order and never reused, because an account the device no longer tracks still owns whatever it holds, and an account can sit unbound. Nothing is bound when a profile is made; an account is taken on first use, when a profile buys a name, so a user who never buys anything has a device that has never derived a key. +Accounts are allocated in order and never reused, because an account the device no longer tracks still owns whatever it holds, and an account can remain unbound. Nothing is bound when a profile is created; an account is bound on first use, when the user buys a name for a profile, so a device on which the user never buys anything has never derived a key. -A hidden profile is bound no account, so it cannot own a name. Two things would leak: the master derives every account, so unlocking any profile also derives a hidden profile's account keys; and a name is written into the profile's own database row and listed across the device, while a hidden profile is a filter on what is shown, not encryption. Closing either is work in the profiles and in the name record, not in the key layout. Incognito is a property of a connection in this app rather than of a profile, so there is nothing at this level to refuse; an incognito connection has no profile of its own to bind an account to. +No account is bound to a hidden profile, so a hidden profile cannot own a name. Two things would leak: the master derives every account, so whoever can use any profile can also derive a hidden profile's account keys; and a name is written into the profile's own database row and listed across the device, while a hidden profile is a filter on what is shown, not encryption. Closing either requires changes in the profiles and in the name record, not in the key layout. Incognito is a property of a connection in this app rather than of a profile, so there is nothing at this level to reject; an incognito connection has no profile of its own to bind an account to. ## Commands -An internal API, called by the names commands and by whatever else takes accounts later, rather than typed by users. Nothing here runs at startup or as a side effect of reading. +These commands are an internal API, called by the names commands and by any later feature that binds accounts, rather than typed by users. Nothing here runs at startup or as a side effect of a read. ``` /_wallet whether the device has a master, and the - accounts the profile is bound to + accounts bound to the profile /_wallet create new generate the master, 24 words -/_wallet create mnemonic= take the master entropy from a phrase, 24 words +/_wallet create mnemonic= import the master entropy from a phrase, 24 words /_wallet bind account= bind an account to the profile and return its address; without account=, the next free one -/_wallet address account= one address, with the account index it came - from; without account=, the next free one +/_wallet address account= one address, with the account index it is + derived at; without account=, the next free one /_wallet export master the master phrase /_wallet export account the secret of an account the profile holds, 0x and 64 hex /_wallet delete delete the master entropy and its accounts ``` -A command that acts on a profile's accounts names the profile and is refused when that profile is not the active one, so switching profiles during a flow cannot bind an account to the wrong profile. +A command that acts on a profile's accounts names the profile and is rejected when that profile is not the active one, so switching profiles during a flow cannot bind an account to the wrong profile. -`create` always names its source, so no key material is made by typing a prefix. An imported phrase must be 24 BIP-39 English words with a valid checksum, normalised for case and spacing. There is no BIP-39 passphrase, because it would be a second secret to back up and losing it would look exactly like losing the phrase, so a phrase used with one elsewhere lands on a different tree here and finds nothing. +`create` always names its source, so no key material is generated by typing a prefix. An imported phrase must be 24 BIP-39 English words with a valid checksum, normalised for case and spacing. There is no BIP-39 passphrase, because it would be a second secret to back up and losing it would look exactly like losing the phrase, so a phrase used with a passphrase elsewhere derives different keys here. -`bind` without an argument takes the next free account from a counter on the master, which is a high-water mark and not a count of what is held, and returns the bound account's address, so the caller knows which account it received. Named with `account=` it takes that one, which is how an account found by a scan is attached to the profile that should have it, and it is refused for an account another profile holds. After an import the counter is unknown rather than zero, because the phrase does not say how many accounts it has been used for, so taking a new one is refused until a scan sets it, while binding a known account is still allowed. +`bind` without an argument binds the account at a counter on the master, which is a high-water mark and not a count of bound accounts, and returns the bound account's index, path and address. With `account=` it binds that account, which is how an account found by a scan is attached to the profile it belongs to, and it is rejected for an account another profile holds. After an import the counter is unknown rather than zero, because the phrase does not encode how many accounts it has been used for, so binding the next account is rejected until a scan sets the counter, while binding a known account is still allowed. -BIP-32 hardens an index by adding 2^31, so an index at or above 2^31 is already a hardened component and derives the same key as the index it wraps onto: account 2^31 is account 0. That is a collision, not a loss of hardening, and it would put one key under two account indexes. Every index this API takes is refused there, including one read from the counter, and the columns carry that bound so that whatever writes them later cannot slip past it. The counter's bound is one higher than an account's, because it holds the next index to hand out, and 2^31 there means every account that can be hardened has been handed out. +BIP-32 marks an index as hardened by setting its top bit, so an index at or above 2^31 already has that bit set and derives the same key as the index 2^31 below it: account 2^31 is account 0. That is a collision, not a loss of hardening, and it would put one key under two account indexes. Every index at or above 2^31 is rejected, including one read from the counter; the simplexmq function that builds the path rejects it too, and the columns have CHECK constraints for that bound, so later writes cannot exceed it. The counter's bound is one higher than an account's, because it contains the next index to bind, and 2^31 there means the counter has passed every index that can be hardened. -`address` reads the counter without moving it, so asking twice gives the same answer, and it works for an account the database has no row for, which is what a device that lost its database needs. One address at a time is enough: a caller scanning the tree loops itself. +`address` reads the counter without changing it, so two calls return the same address, and it derives an address for an account the database has no row for, which a device that lost its database requires. One address per call is sufficient: a caller that scans the tree calls it in a loop. -`export account` is refused unless the profile holds the account, so every exported key belongs to an account the counter has already passed, and no later `bind` returns it as a new account. An export is a copy and not a handover: the device still derives what it exported and can still sign with it, so giving an account key away leaves two parties able to act as its owner until whatever it holds is transferred on chain. Signing is not in this change, and when it is added it is a command here that signs and returns a signature, not `export account` followed by signing elsewhere, which would make the narrow export the ordinary path. `delete` leaves accounts registered to their addresses, reachable only by the phrase. +`export account` is rejected unless the profile holds the account, so every exported key belongs to an account that is already bound, which `bind` without an index never returns: the counter is either past it or unknown. An export is a copy and not a transfer: the device still derives what it exported and can still sign with it, so giving an account key away leaves two parties able to act as its owner until whatever it holds is transferred on chain. Signing is not in this change, and when it is added it is a command here that signs and returns a signature, not `export account` followed by signing elsewhere, which would make the narrow export the ordinary path. `delete` leaves accounts registered to their addresses, recoverable only from the phrase. ```haskell data WalletAddress = WalletAddress {accountIndex :: Word32, keyPath :: Text, address :: Text} data WalletError = WENoMaster -- the device has no master entropy - | WEMasterExists -- create, when it already has one + | WEMasterExists -- create, when the device already has one | WEBadMnemonic -- wrong word count, wrong word, or bad checksum - | WEHiddenProfile -- bind, on a profile the app hides + | WEHiddenProfile -- bind, on a hidden profile | WEAccountBound -- bind, on an account another profile holds | WEAccountNotHeld -- export account, on an account the profile does not hold - | WECounterUnknown -- no counter to read yet, after an import + | WECounterUnknown -- the counter is not set yet, after an import | WEIndexTooLarge -- at or above 2^31 | WEDerivation {derivationError :: String} -- BIP-32 or BIP-39 derivation failed ``` ## Recovery -Three cases, by how much of the database came back. +Three cases, by how much of the database is restored. -**Only the phrase is left.** A phrase carries entropy and nothing else. Which accounts were used is not in it, so the counter starts out unknown and only a scan of the chain sets it; which profile held which account is not in it either, and nothing recovers that. The scan is a walk over `address`, one call for each candidate: account 0, then 1, and so on, asking what each address owns and stopping after a run that owns nothing. Asking what an address owns, deciding how long that run has to be, and recording the answers belong to the caller, because the wallet never sees an answer. Nothing outside this API writes these tables, so a command landing with the scan writes a row for each account found, unbound, and moves the counter past them. +**Only the phrase is left.** A phrase encodes the entropy and nothing else. Which accounts were used is not encoded in it, so the counter starts out unknown and only a scan of the chain sets it; which profile held which account is not encoded in it either, and nothing recovers that. The scan is a sequence of `address` calls, one for each candidate: account 0, then 1, and so on, querying what each address owns and stopping after a run of addresses that own nothing. Querying what an address owns, choosing the length of that run, and recording the results are the caller's responsibility, because the wallet never receives the results. Nothing outside this API adds rows to these tables, so a command added together with the scan writes a row for each account found, unbound, and moves the counter past every account row, including accounts bound by index that the scan does not find. -What the scan finds is unbound, and the user attaches each account to a profile with `bind account=`. The names those accounts own are what identify them, which is what makes the question answerable at all: the user is choosing between names they recognise, not between numbers. Binding moves no key and signs nothing, because ownership does not change, only which profile the app shows the account under. Pointing a name at that profile's address is a separate signed edit of the name's record. +What the scan finds is unbound, and the user attaches each account to a profile with `bind account=`. The names those accounts own identify them, which is what makes the choice possible at all: the user chooses between names they recognise, not between numbers. Binding changes no key and signs nothing, because ownership does not change, only the profile under which the app shows the account. Pointing a name at that profile's address is a separate signed edit of the name's record. -**The database is older than the master.** It has the accounts as of the backup and nothing written after it, so its counter is behind. A counter that is behind is worse than one that is unknown, because it looks usable and hands out an account the master has already used. Nothing here can tell a restored database from a current one, so clearing the counter belongs to whatever restores one, along with the scan that fills it in again. +**The database is restored from a backup.** It contains the accounts as of the backup and nothing written after it, so its counter is behind. A counter that is behind is worse than one that is unknown, because it appears valid, so `bind` returns an account that is already in use. Nothing here distinguishes a restored database from a current one, so clearing the counter is the responsibility of whatever restores a database, along with the scan that sets it again. -**The database is current.** It records which profile holds which account, so nothing is asked of the user. +**The database is current.** It records which profile holds which account, so the user is asked nothing. ## Schema @@ -126,64 +126,63 @@ CREATE TABLE wallet_seeds ( entropy BLOB NOT NULL CHECK (length(entropy) = 32), 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; CREATE TABLE wallet_accounts ( wallet_account_id INTEGER PRIMARY KEY AUTOINCREMENT, wallet_seed_id INTEGER NOT NULL REFERENCES wallet_seeds ON DELETE CASCADE, account_index INTEGER CHECK (account_index BETWEEN 0 AND 2147483647), -- null when the key was imported user_id INTEGER REFERENCES users ON DELETE SET NULL -); +) STRICT; CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON wallet_seeds(single_seed); CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_accounts(wallet_seed_id, account_index); CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id); ``` -Only entropy that nothing can derive is stored: the master, always 32 bytes, since it is made and imported as 24 words. An account key is never stored, because the master entropy and an account index derive it whenever one is needed. So `wallet_accounts` holds what derivation cannot produce, which account indexes the device knows about and which profile each belongs to. A row with no `user_id` is an account no profile holds, which is what a deleted chat profile leaves behind and what a scan writes. +Only entropy that nothing can derive is stored: the master, always 32 bytes, since it is generated and imported as 24 words. An account key is never stored, because the master entropy 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. -`users` is not touched: the mapping lives on the account row, and the index on `user_id` is not unique, because a profile owns as many accounts as it owns names. One seed per device is `single_seed` and the unique index on it, which a later change lifts with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop one of those without rebuilding the table. Deleting the master takes its account rows, because an account index with no entropy behind it derives nothing. The migration has no reverse step, because reversing it would drop the only copy of the master entropy. A reverse step 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. With no reverse step the older app reports that the database is newer than it is, 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 owns as many accounts as it owns names. 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. -A null `account_index` marks an account whose key was imported rather than derived, which the master phrase does not recover and the schema must not suggest it does. Importing one is not implemented here; the column is nullable now so that a row written later reads correctly. 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. +A null `account_index` marks an account whose key was imported rather than derived, which the master phrase does not recover and the schema must not suggest it does. Importing one is not implemented here; the column is nullable now so that a row written later 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. ## Threat model -- **Someone with the database file.** Gets everything, now and later: the stored entropy is the master phrase in another encoding, so an archive exported to move devices carries every key on the device. No export granularity helps against a file copy. On SQLite the connection sets `secure_delete`, so a deleted row's pages are zeroed; the journal and any copy already taken are not. -- **Someone with one account key.** Can act as that account's owner permanently, because an export is a copy and the device keeps deriving the same key. Cannot reach another account. -- **A wallet the master phrase is imported into.** Enumerating BIP-44 accounts computes account extended public keys, and some wallets send them to a vendor, which hands that vendor every account on the device at once, across every profile. That is what an account for each name otherwise prevents. -- **Whoever answers the recovery scan.** Sees every address the phrase could hold a name on, in one burst, so it links every account on the device, across profiles, and recognises addresses that hold nothing yet, which is where future accounts will be. `address` derives for any index straight from the master, so a caller can enumerate hidden profiles' addresses too. This is the sharpest cost in the design. -- **A paired device.** Can run any of these commands, because they are not blocked from one: `export master` reads the whole wallet, `create` on a device that has none plants a seed the pairing controls, and `delete` destroys the only copy. Blocking `ExecChatStoreSQL` while allowing `export master` is not a coherent line, and the wallet commands need their own decision rather than the catch-all. -- **Someone reading the logs.** The core logs no command and no answer, so a phrase typed into `create` reaches no log. -- **A page open in the user's browser.** The websocket server in `apps/simplex-chat/Server.hs` accepts any local connection, asks for no token and checks no `Origin`, and websockets are not bound by the same origin policy, so any page loaded while that server runs can send `export master` and read the answer. It also prints every command it receives, that phrase included. Both are properties of that server, which this change gives something worth taking, and closing them is work there rather than in the wallet. +- **Someone with the database file.** Gets everything, now and later: the stored entropy is the master phrase in another encoding, so an archive exported to move devices contains every key on the device. No export granularity protects against a file copy. On SQLite the connection sets `secure_delete`, so a deleted row's pages are zeroed; the journal and any copy already made are not. +- **Someone with one account key.** Can act as that account's owner permanently, because an export is a copy and the device keeps deriving the same key. Cannot derive another account's key. +- **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` on a device that has no seed creates a seed chosen by the paired device, 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. +- **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 -1. **There is no per-profile handover.** The master phrase is every account on the device and an account secret is one account, with nothing in between, so a user who wants to hand one profile's names to another wallet hands over one key for each name. -2. **The phrase alone does not restore a device.** Until the scan lands, an imported phrase gives a device its keys and no way to find what they own. -3. **The scan's shape is unsettled.** Going from an address to the names it owns is not something a registry answers on chain, so it needs the registrar or an indexer, and which one decides who sees the scan. How long a run of empty accounts ends the walk is a guess until it is written, and a user who knows better has to be able to send it further. -4. **The same phrase on two devices collides.** The counter lives in one database, so both take the same account and each believes it is free. Sharing it needs a backup both can read. -5. **A profile hidden after it was bound keeps its accounts.** The check is at bind time only. -6. **Account indexes are not dense.** An account can be taken and never used, and a run of empty accounts is how the scan stops, so one far above a gap can be missed. -7. **Gas and discovery pull against each other.** If an account ever pays for anything, whatever funds it links accounts on chain. If it never pays, no wallet finds it past account 0. -8. **Nothing records which layout a seed was used under.** A phrase used in another wallet may hold accounts at paths this doc does not describe. -9. **A restored database hands out an account that is already used.** Its counter is behind what the master has reached, and nothing detects that, so a name can be bought with an account that already owns one until the scan resets the counter. -10. **Purpose `5564'` is ours.** Reserved for stealth keys, taken from an [ERC-5564](https://eips.ethereum.org/EIPS/eip-5564) number rather than registered as a BIP-43 purpose, and nothing here derives at it. +1. **There is no per-profile transfer.** The master phrase derives every account on the device and an account secret controls one account, with nothing in between, so a user who wants to move one profile's names to another wallet has to export one key for each name. +2. **The phrase alone does not restore a device.** Until the scan is implemented, an imported phrase gives a device its keys and no way to find what they own. +3. **The scan's design is not decided.** Finding the names an address owns is not a query a registry answers on chain, so it requires the registrar or an indexer, and that choice determines which party observes the scan. The length of the run of empty accounts that ends the scan is not yet decided, and the user must be able to extend the scan. +4. **The same phrase on two devices collides.** The counter is stored in one database, so both devices bind the same account and each treats it as free. Sharing the counter requires a backup both devices can read. +5. **A profile hidden after an account was bound to it keeps its accounts.** The check runs at bind time only. +6. **Account indexes are not dense.** An account can be bound and never used, and a run of empty accounts ends the scan, so an account after a gap can be missed. +7. **Gas payments and discovery conflict.** If an account ever pays for anything, whatever funds it links accounts on chain. If it never pays, no wallet discovers it past account 0. +8. **Nothing records which layout a seed was used with.** A phrase used in another wallet may own accounts at paths this doc does not describe. +9. **A restored database binds an account that is already in use.** Its counter is below the highest index used, and nothing detects that, so a name can be bought with an account that already owns one until the scan resets the counter. -## Files +## Main files - `src/Simplex/Chat/Wallet.hs`, derivation. - `src/Simplex/Chat/Store/Wallets.hs`, the two tables. -- `src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs` and the Postgres twin. +- `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 what to test by taking every migration after the last one without a reverse step, and now take every migration from the first one that has a reverse step, applying any that has none. -- Derivation uses the `BIP32`, `BIP39` and `Eth.Address` modules from simplexmq (simplex-chat/simplexmq#1843) and adds no dependency to this package. +- `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 **File:** `tests/WalletTests.hs`. Each of these is a test, not a claim. -1. **Vectors.** The two addresses above reproduce from `abandon ... about`, as does account 0's secret, pinned to the value another wallet shows for it. A 24 word phrase imported through the command reaches a pinned address end to end, so a change of path fails here rather than shipping, and the account a command names is the account whose key comes back. -2. **Isolation.** Ten accounts' addresses are all different, and an account path hardens its account component. -3. **Refusals.** A second generate; a phrase that is not 24 valid words; `bind`, `delete` and `export master` on a device with no wallet; `bind` on a hidden profile, on an account another profile holds, and on an imported master whose counter is unknown; `export account` for an account the profile does not hold; every index at or above 2^31, on `address`, `bind` and `export account` alike. -4. **Binding and reads.** A profile binds several accounts, each `bind` returns the account it bound, and the profile exports its own, an account bound by index moves the counter past it so the next one does not collide and never moves it back, `bind account=` attaches a scanned one, an account a deleted profile leaves behind is taken by another profile, and `address` returns the counter twice running without moving it and derives for an account with no row. -5. **Encoding and persistence.** An account secret whose first byte is zero keeps its 64 hex digits; the wallet, its accounts, the counter and the phrase survive a restart; and deleting the wallet takes its accounts and starts the counter over. +1. **Vectors.** The two addresses above are derived from `abandon ... about`, as is account 0's secret, pinned to the value another wallet shows for it. A 24 word phrase imported through the command derives a pinned address end to end, so a change of path fails here rather than in a release, and the account a command names is the account whose key is returned. +2. **Isolation.** Ten accounts' addresses are all different, and the paths of accounts 0 and 7 have a hardened account component. +3. **Rejections.** A second generate; a phrase that is not 24 valid words; `bind`, `delete` and `export master` on a device with no wallet; `bind` on a hidden profile, on an account another profile holds, and on an imported master whose counter is unknown; `export account` for an account the profile does not hold; index 2^31, on `address`, `bind` and `export account` alike; an index of 2^32 or more is rejected as a bad command. +4. **Binding and reads.** A profile binds several accounts, each `bind` returns the account it bound, and the profile exports its own; an account bound by index moves the counter past it so the next one does not collide, and never moves it back; `bind account=` attaches a scanned one; an account left unbound by deleting its profile is bound to another profile; and two consecutive `address` calls return the same address without changing the counter, and derive an address for an account with no row. +5. **Encoding and persistence.** An account secret whose first byte is zero is rendered with 64 hex digits; the wallet, its accounts, the counter and the phrase persist across a restart; and deleting the wallet deletes its accounts and resets the counter. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 3e6b1fd6ec..da0561a19c 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."714803f7a00f51379621b131ffa561d7b61c26d2" = "1ki45ifixfgdkyg0xrgirr9w8821aszfz304l3fb4fbv0aq4dk83"; + "https://github.com/simplex-chat/simplexmq.git"."256ef554486e5de67401c3d9d0e5ce2c35402247" = "1nw1y8apy8s63i2w9vapzyyhknxppq11c6hamsnz5agccp3mvhir"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 8214b686d2..396783d64c 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -65,7 +65,6 @@ 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.Store.Wallets (WalletSeed (..), accountHeldBy, bindAccount, createWalletSeed, deleteWalletSeed, getUserAccounts, getWalletSeed, resolveAccount) import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress, WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, newSeedEntropy, seedMnemonic) import Simplex.Chat.Call import Simplex.Chat.Controller @@ -97,6 +96,7 @@ import Simplex.Chat.Store.Messages import Simplex.Chat.Store.NoteFolders import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared +import Simplex.Chat.Store.Wallets (WalletSeed (..), accountHeldBy, bindAccount, createWalletSeed, deleteWalletSeed, getUserAccounts, getWalletSeed, resolveAccount) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -1507,7 +1507,7 @@ processChatCommand cxt nm = \case -- the counter starts at 0 for a generated seed and is unknown for an imported one (entropy, nextAccount) <- case mnemonic_ of Nothing -> (,Just 0) <$> (asks random >>= atomically . newSeedEntropy) - Just phrase -> (,Nothing) <$> liftWallet (entropyFromMnemonic $ encodeUtf8 phrase) + Just phrase -> (,Nothing) <$> liftWallet (entropyFromMnemonic phrase) created <- withFastStore' $ \db -> createWalletSeed db entropy nextAccount unless created $ throwWalletError WEMasterExists pure $ CRWallet user (Just []) @@ -6029,13 +6029,13 @@ throwWalletError :: WalletError -> CM a throwWalletError = throwChatError . CEWallet liftWallet :: Either WalletError a -> CM a -liftWallet = either throwWalletError pure +liftWallet = liftEitherWith (ChatError . CEWallet) withWalletStore :: (DB.Connection -> IO (Either WalletError a)) -> CM a withWalletStore action = liftWallet =<< withFastStore' action seedAccount :: WalletSeed -> AccountIndex -> CM (AccountKey, WalletAddress) -seedAccount WalletSeed {wsEntropy} n = liftWallet =<< liftIO (deriveAccount wsEntropy n) +seedAccount WalletSeed {wsEntropy} n = liftError' (ChatError . CEWallet) (deriveAccount wsEntropy n) chatCommandP :: Parser ChatCommand chatCommandP = 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 68fff6d313..e32d0dc2f5 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs @@ -28,4 +28,4 @@ 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 reverse step, see the SQLite migration +-- no down migration, see the SQLite migration 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 4d998fe751..dec9eb8cfb 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs @@ -27,4 +27,4 @@ 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 reverse step: it would drop the only copy of the master entropy. +-- No down migration: it would delete the master entropy, which may have no other copy. 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 c04877014a..fa121c4719 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -4416,6 +4416,14 @@ Query: Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE wallet_accounts SET user_id = ? + WHERE wallet_seed_id = ? AND account_index = ? AND user_id IS NULL + RETURNING wallet_account_id + +Plan: +SEARCH wallet_accounts USING INDEX idx_wallet_accounts_wallet_seed_id_account_index (wallet_seed_id=? AND account_index=?) + Query: WITH user_groups AS MATERIALIZED ( SELECT g.group_id @@ -5723,13 +5731,6 @@ Query: Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) -Query: - UPDATE wallet_accounts SET user_id = ? - WHERE wallet_seed_id = ? AND account_index = ? AND user_id IS NULL - -Plan: -SEARCH wallet_accounts USING INDEX idx_wallet_accounts_wallet_seed_id_account_index (wallet_seed_id=? AND account_index=?) - Query: UPDATE wallet_seeds SET next_account_index = ? WHERE wallet_seed_id = ? AND next_account_index IS NOT NULL AND next_account_index <= ? diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index 677d2057bd..7fdc5e5548 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -91,7 +91,7 @@ getUserAccounts db sId userId = |] (sId, userId) --- | Which profile holds an account: 'Nothing' when it is unknown, @Just Nothing@ when no profile holds it. +-- | Which profile holds an account: 'Nothing' when there is no row for it, @Just Nothing@ when no profile holds it. accountUser :: DB.Connection -> SeedId -> AccountIndex -> IO (Maybe (Maybe UserId)) accountUser db sId n = maybeFirstRow fromOnly $ @@ -100,24 +100,25 @@ accountUser db sId n = bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> IO (Either WalletError (WalletSeed, AccountIndex)) bindAccount db userId accountIdx_ = runExceptT $ do r@(WalletSeed {wsId}, n) <- ExceptT $ resolveAccount db accountIdx_ - taken <- liftIO $ accountUser db wsId n >>= \case + held <- liftIO $ accountUser db wsId n >>= \case Just (Just heldBy) -> pure $ heldBy == userId - -- the update sets user_id only while it is NULL, so the read after it shows which profile holds the account - Just Nothing -> setAccountUser db wsId userId n >> accountHeldBy db wsId userId n + Just Nothing -> setAccountUser db wsId userId n Nothing -> True <$ insertAccount db wsId userId n - unless taken $ throwError WEAccountBound + unless held $ throwError WEAccountBound liftIO $ raiseNextAccount db wsId n pure r -setAccountUser :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO () +setAccountUser :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bool setAccountUser db sId userId n = - DB.execute - db - [sql| - UPDATE wallet_accounts SET user_id = ? - WHERE wallet_seed_id = ? AND account_index = ? AND user_id IS NULL - |] - (userId, sId, n) + fmap isJust . maybeFirstRow (fromOnly @Int64) $ + DB.query + db + [sql| + UPDATE wallet_accounts SET user_id = ? + WHERE wallet_seed_id = ? AND account_index = ? AND user_id IS NULL + RETURNING wallet_account_id + |] + (userId, sId, n) accountHeldBy :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bool accountHeldBy db sId userId n = (== Just (Just userId)) <$> accountUser db sId n diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5433c74958..108ad05ba3 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1119,10 +1119,10 @@ walletErrorText = \case WENoMaster -> "this device has no wallet" WEMasterExists -> "this device already has a wallet" WEBadMnemonic -> "not a valid 24 word recovery phrase" - WEHiddenProfile -> "a hidden profile cannot own an account" + WEHiddenProfile -> "a hidden profile cannot hold an account" WEAccountBound -> "another profile holds this account" WEAccountNotHeld -> "this profile does not hold this account" - WECounterUnknown -> "the next account is unknown after an import, scan the chain first" + WECounterUnknown -> "the next account is unknown after an import" WEIndexTooLarge -> "account index is too large to harden" WEDerivation e -> "derivation failed: " <> T.pack e diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index d6a76ac496..16e7218891 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -23,7 +23,6 @@ import qualified Data.Aeson.TH as JQ import Data.Bifunctor (bimap, first) import qualified Data.ByteArray as BA import qualified Data.ByteArray.Encoding as BAE -import Data.ByteString (ByteString) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1) import Data.Word (Word32) @@ -69,7 +68,7 @@ masterStrength = B39.MS256 newSeedEntropy :: TVar ChaChaDRG -> STM BA.ScrubbedBytes newSeedEntropy g = B39.mnemonicToEntropy <$> B39.randomMnemonic masterStrength g -entropyFromMnemonic :: ByteString -> Either WalletError BA.ScrubbedBytes +entropyFromMnemonic :: Text -> Either WalletError BA.ScrubbedBytes entropyFromMnemonic phrase = case B39.parseMnemonic phrase of Right m | length (B39.mnemonicWords m) == B39.strengthWordCount masterStrength -> Right $ B39.mnemonicToEntropy m diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 5e1fdc94bb..4386f507eb 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -30,7 +30,7 @@ testPhrase24 :: ByteString testPhrase24 = B.unwords $ replicate 23 "abandon" <> ["art"] seedEntropy :: ByteString -> BA.ScrubbedBytes -seedEntropy phrase = B39.mnemonicToEntropy . either error id $ B39.parseMnemonic phrase +seedEntropy phrase = B39.mnemonicToEntropy . either error id $ B39.parseMnemonic (safeDecodeUtf8 phrase) walletAccount :: BA.ScrubbedBytes -> AccountIndex -> IO (AccountKey, WalletAddress) walletAccount entropy n = either (error . show) id <$> deriveAccount entropy n @@ -50,7 +50,7 @@ accountBound cc idx = (take 1 . words <$> getTermLine cc) `shouldReturn` [idx] walletDerivationTests :: Spec walletDerivationTests = do - Hspec.it "accounts are the accounts another wallet derives for the same phrase" $ do + Hspec.it "derives the addresses another wallet derives for the same phrase" $ do let addrOf n = address . snd <$> walletAccount (seedEntropy testPhrase12) n addrOf 0 `shouldReturn` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" addrOf 1 `shouldReturn` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265" @@ -60,7 +60,7 @@ walletDerivationTests = do Hspec.it "every account has its own address" $ do addrs <- mapM (fmap (address . snd) . walletAccount (seedEntropy testPhrase12)) [0 .. 9] length (nub addrs) `shouldBe` 10 - Hspec.it "a secret whose first byte is zero keeps its 64 hex digits" $ do + Hspec.it "renders a secret whose first byte is zero with 64 hex digits" $ do k <- either error id <$> S.mkPrivateKey (BA.convert $ B.pack ('\0' : replicate 31 '\1')) let secret = T.unpack $ accountSecret k take 4 secret `shouldBe` "0x00" @@ -68,30 +68,30 @@ walletDerivationTests = do Hspec.it "renders the path an account is derived at" $ do (keyPath . snd <$> walletAccount (seedEntropy testPhrase12) 0) `shouldReturn` "m/44'/60'/0'/0/0" (keyPath . snd <$> walletAccount (seedEntropy testPhrase12) 7) `shouldReturn` "m/44'/60'/7'/0/0" - Hspec.it "refuses an index BIP-32 cannot harden" $ + Hspec.it "rejects an account index at or above 2^31" $ (void <$> deriveAccount (seedEntropy testPhrase12) 2147483648) `shouldReturn` Left WEIndexTooLarge Hspec.it "round-trips the phrase it was imported from" $ seedMnemonic (seedEntropy testPhrase24) `shouldBe` Right (safeDecodeUtf8 testPhrase24) - Hspec.it "takes 24 words only, with a valid checksum" $ do - entropyFromMnemonic testPhrase24 `shouldSatisfy` isRight - entropyFromMnemonic testPhrase12 `shouldBe` Left WEBadMnemonic - entropyFromMnemonic (B.unwords $ replicate 24 "abandon") `shouldBe` Left WEBadMnemonic + Hspec.it "accepts only 24 words with a valid checksum" $ do + entropyFromMnemonic (safeDecodeUtf8 testPhrase24) `shouldSatisfy` isRight + entropyFromMnemonic (safeDecodeUtf8 testPhrase12) `shouldBe` Left WEBadMnemonic + entropyFromMnemonic (T.unwords $ replicate 24 "abandon") `shouldBe` Left WEBadMnemonic walletTests :: SpecWith TestParams walletTests = do - it "creates no wallet until asked, and only one" testWalletCreate - it "binds the next free account, and re-binding one it holds changes nothing" testWalletBind + it "creates a wallet only on the create command, and at most one" testWalletCreate + it "binds the next free account, and re-binding an account the profile holds changes nothing" testWalletBind it "keeps each profile's accounts apart" testWalletAccountsPerProfile - it "taking the next account skips one already bound by index" testWalletBindByIndexThenNext - it "leaves a deleted profile's account for another profile to take" testWalletDeletedProfileAccount - it "derives an address without taking it" testWalletAddress + it "binding the next account skips one already bound by index" testWalletBindByIndexThenNext + it "a deleted profile's account can be bound to another profile" testWalletDeletedProfileAccount + it "derives an address without binding it" testWalletAddress it "exports the master phrase and one account's secret" testWalletExport - it "will not take a new account on an imported phrase" testWalletImport - it "the wallet, the accounts and the counter come back after a restart" testWalletPersists - it "deletes the wallet, and one can be made again" testWalletDelete + it "does not bind the next account after an import" testWalletImport + it "the wallet, the accounts and the counter persist across a restart" testWalletPersists + it "deletes the wallet, and a new one can be created" testWalletDelete it "does not bind an account to a hidden profile" testWalletHiddenProfile it "exports only an account the profile holds" testWalletExportNotHeld - it "refuses an index BIP-32 cannot harden, on every command" testWalletIndexTooLarge + it "rejects an account index at or above 2^31 on every command" testWalletIndexTooLarge testWalletCreate :: HasCallStack => TestParams -> IO () testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -224,13 +224,13 @@ testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase24) alice <## "wallet, no accounts for this profile" alice ##> "/_wallet bind 1" - alice <## "wallet: the next account is unknown after an import, scan the chain first" + alice <## "wallet: the next account is unknown after an import" alice ##> "/_wallet address" - alice <## "wallet: the next account is unknown after an import, scan the chain first" + alice <## "wallet: the next account is unknown after an import" alice ##> "/_wallet bind 1 account=4" alice `accountBound` "4" alice ##> "/_wallet bind 1" - alice <## "wallet: the next account is unknown after an import, scan the chain first" + alice <## "wallet: the next account is unknown after an import" testWalletPersists :: HasCallStack => TestParams -> IO () testWalletPersists ps = do @@ -275,7 +275,7 @@ testWalletHiddenProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> alice <## "messages are hidden (use /tail to view)" alice <## "profile is hidden" alice ##> "/_wallet bind 2" - alice <## "wallet: a hidden profile cannot own an account" + alice <## "wallet: a hidden profile cannot hold an account" testWalletExportNotHeld :: HasCallStack => TestParams -> IO () testWalletExportNotHeld ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do