diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 203e790f1e..63bc9a6e7b 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -212,6 +212,7 @@ This file is generated automatically. - [UserProfileUpdateSummary](#userprofileupdatesummary) - [UserPwdHash](#userpwdhash) - [VersionRange](#versionrange) +- [WalletError](#walleterror) - [XFTPErrorType](#xftperrortype) - [XFTPRcvFile](#xftprcvfile) - [XFTPSndFile](#xftpsndfile) @@ -1158,6 +1159,10 @@ SimplexDomainNotReady: - simplexDomain: [SimplexDomain](#simplexdomain) - simplexDomainError: [SimplexDomainError](#simplexdomainerror) +Wallet: +- type: "wallet" +- walletError: [WalletError](#walleterror) + NotResolvedLocally: - type: "notResolvedLocally" @@ -4494,6 +4499,38 @@ Handshake: - maxVersion: int +--- + +## WalletError + +**Discriminated union type**: + +NoMaster: +- type: "noMaster" + +MasterExists: +- type: "masterExists" + +BadMnemonic: +- type: "badMnemonic" + +HiddenProfile: +- type: "hiddenProfile" + +AccountBound: +- type: "accountBound" + +CounterUnknown: +- type: "counterUnknown" + +IndexTooLarge: +- type: "indexTooLarge" + +Derivation: +- type: "derivation" +- derivationError: string + + --- ## XFTPErrorType diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index a12f2bb34b..eda162616d 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -340,6 +340,7 @@ undocumentedCommands = "APIAddGroupShortLink", "APIAddMyAddressShortLink", "APIArchiveReceivedReports", + "APIBindWalletAccount", "APICallStatus", "APIChangeConnectionUser", "APIChangePreparedContactUser", @@ -359,7 +360,9 @@ undocumentedCommands = "APICreateMemberContact", "APISendMemberContactInvitation", "APIAcceptMemberContact", + "APICreateWallet", "APIDeleteChatTag", + "APIDeleteWallet", "APIDeleteMemberSupportChat", "APIDeleteReceivedReports", "APIDeleteStorage", @@ -370,6 +373,8 @@ undocumentedCommands = "APIEndCall", "APIExportArchive", "APIForwardChatItems", + "APIExportWalletAccount", + "APIExportWalletMnemonic", "APIGetAppSettings", "APIGetCallInvitations", "APIGetChat", @@ -388,6 +393,8 @@ undocumentedCommands = "APIGetServerOperators", "APIGetUsageConditions", "APIGetUserServers", + "APIGetWallet", + "APIGetWalletAddress", "APIGroupInfo", "APIGetUpdatedGroupLinkData", "APIGroupMemberInfo", @@ -450,11 +457,6 @@ undocumentedCommands = "APIVerifyContactDomain", "APIVerifyGroupMember", "APIVerifyToken", - "APIWallet", - "APIWalletCreate", - "APIWalletDelete", - "APIWalletExportNameSecret", - "APIWalletExportSeedMnemonic", "CheckChatRunning", "ConfirmRemoteCtrl", "ConnectRemoteCtrl", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 9facd2fd3e..40bb918b92 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -213,7 +213,8 @@ undocumentedResponses = "CRUserServersValidation", "CRVersionInfo", "CRWallet", - "CRWalletDerivedSecret", - "CRWalletSeedMnemonic", + "CRWalletAccountSecret", + "CRWalletAddress", + "CRWalletMnemonic", "CRWelcome" ] diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index e0af1f3ff5..babbbe4e3d 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -36,6 +36,7 @@ import Simplex.Chat.Operators import Simplex.Messaging.Agent.Store.Entity (DBStored (..)) import Simplex.Chat.Badges import Simplex.Chat.Names +import Simplex.Chat.Wallet (WalletError (..)) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -394,6 +395,7 @@ chatTypesDocsData = (sti @UserInfo, STRecord, "", [], "", ""), (sti @UserProfileUpdateSummary, STRecord, "", [], "", ""), (sti @UserPwdHash, STRecord, "", [], "", ""), + (sti @WalletError, STUnion, "WE", [], "", ""), (sti @XFTPErrorType, STUnion, "", [], "", ""), (sti @XFTPRcvFile, STRecord, "", [], "", ""), (sti @XFTPSndFile, STRecord, "", [], "", ""), @@ -626,6 +628,9 @@ deriving instance Generic UserContactRequest deriving instance Generic UserInfo deriving instance Generic UserProfileUpdateSummary deriving instance Generic UserPwdHash + +deriving instance Generic WalletError + deriving instance Generic XFTPErrorType deriving instance Generic XFTPRcvFile deriving instance Generic XFTPSndFile diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 357f583db6..6aae485342 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -58,7 +58,7 @@ An account is bound to at most one chat profile, and a profile to any number of 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. -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. An incognito profile is meant to leave nothing behind, so it is bound none either. +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. ## Commands @@ -82,11 +82,11 @@ An internal API, called by the names commands and by whatever else takes account `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. 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. -BIP-32 hardens an index by adding 2^31, so an index at or above 2^31 wraps into one that is not hardened, which is a silent loss of hardening rather than a collision; every index this API takes is refused there. +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. `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. -`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 lands 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 refused for an account another profile holds, because that key is not this profile's to hand out. 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 lands 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. ```haskell data WalletAddress = WalletAddress {accountIndex :: Word32, keyPath :: Text, address :: Text} @@ -96,9 +96,10 @@ data WalletError | WEMasterExists -- create, when it already has one | WEBadMnemonic -- wrong word count, wrong word, or bad checksum | WEHiddenProfile -- bind, on a profile the app hides - | WEAccountBound -- bind, on an account another profile holds + | WEAccountBound -- bind or export account, on an account another profile holds | WECounterUnknown -- no counter to read yet, after an import | WEIndexTooLarge -- at or above 2^31 + | WEDerivation {derivationError :: String} -- BIP-32 or BIP-39 said no ``` ## Recovery @@ -119,14 +120,14 @@ 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) = 32), - next_account_index INTEGER, -- null means not known yet + next_account_index INTEGER CHECK (next_account_index BETWEEN 0 AND 2147483648), -- null means not known yet single_seed INTEGER NOT NULL DEFAULT 1 ); 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, -- null when the key was imported + 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 ); @@ -137,17 +138,18 @@ CREATE INDEX idx_wallet_accounts_user ON wallet_accounts(user_id); Only entropy that nothing can derive is stored: the master, always 32 bytes, since it is made and imported as 24 words. An account key is never stored, because the master entropy and an account index derive it whenever one is needed. So `wallet_accounts` holds what derivation cannot produce, which account indexes the device knows about and which profile each belongs to. A row with no `user_id` is an account no profile holds, which is what a deleted chat profile leaves behind and what a scan writes. -`users` is not touched: the mapping lives on the account row, and the index on `user_id` is not unique, because a profile owns as many accounts as it owns names. One seed per device is `single_seed` and the unique index on it, which a later change lifts with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop one of those without rebuilding the table. Deleting the master takes its account rows, because an account index with no entropy behind it derives nothing, and the migration has no reverse step, because dropping `wallet_seeds` would destroy the only copy of the master entropy. +`users` is not touched: the mapping lives on the account row, and the index on `user_id` is not unique, because a profile owns as many accounts as it owns names. One seed per device is `single_seed` and the unique index on it, which a later change lifts with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop one of those without rebuilding the table. Deleting the master takes its account rows, because an account index with no entropy behind it derives nothing. The migration does have a reverse step, which the schema test exercises, and running it destroys the only copy of the master entropy, so it is for development and never for a device holding anything. A null `account_index` marks an account whose key was imported rather than derived, which the master phrase does not recover and the schema must not suggest it does. Importing one is not implemented here; the column is nullable now so that a row written later reads correctly, rather than leaving an unmarked row to be guessed at. ## 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, and `delete` does not overwrite, so a deleted row survives in free pages and in the journal. +- **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, `export master` included, because they are not blocked from one. Only the command text is kept out of logs; the answer is not. +- **A paired device.** Can run any of these commands, because they are not blocked from one: `export master` reads the whole wallet, `create` on a device that has none plants a seed the pairing controls, and `delete` destroys the only copy. Blocking `ExecChatStoreSQL` while allowing `export master` is not a coherent line, and the wallet commands need their own decision rather than the catch-all. +- **Someone reading the logs.** A remote session logs a command's verb and nothing else, so a phrase typed into `create` stays out of the log, and an answer is never logged at all. The websocket server in `apps/simplex-chat/Server.hs` prints every command it receives, that phrase included, which is a change to that server rather than to the wallet. ## Known limits @@ -173,8 +175,8 @@ A null `account_index` marks an account whose key was imported rather than deriv **File:** `tests/WalletTests.hs`. Each of these is a test, not a claim. -1. **Vectors.** The two addresses above reproduce from `abandon ... about`, and the master phrase imported into a wallet that enumerates BIP-44 accounts reaches the same ones. -2. **Isolation.** Ten accounts' addresses do not intersect, and an account path hardens its account component. -3. **Refusals.** A second generate or import; a bad phrase; `bind` on a hidden profile and on an account another profile holds, and taking a new account on an imported master; `export` with no argument; every index at or above 2^31. -4. **Binding and reads.** A profile binds several accounts, `bind account=` attaches a scanned one, `address` returns the counter twice running without moving it and derives for an account with no row. -5. **Encoding and persistence.** An account secret whose first byte is zero keeps its 64 hex digits, and keys and addresses survive a restart. +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. +2. **Isolation.** Ten accounts' addresses are all different, and an account path hardens its account component. +3. **Refusals.** A second generate; a phrase that is not 24 valid words; `bind` on a hidden profile, on an account another profile holds, and on an imported master whose counter is unknown; `export account` for an account another profile holds; every index at or above 2^31, on `address`, `bind` and `export account` alike. +4. **Binding and reads.** A profile binds several accounts, an account bound by index moves the counter past it so the next one does not collide, `bind account=` attaches a scanned one, and `address` returns the counter twice running without moving it and derives for an account with no row. +5. **Encoding and persistence.** An account secret whose first byte is zero keeps its 64 hex digits, and the wallet, its accounts and the phrase survive a restart. diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 09c88069e2..a8c131bcff 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -1099,6 +1099,7 @@ export type ChatErrorType = | ChatErrorType.ChatStoreChanged | ChatErrorType.InvalidConnReq | ChatErrorType.SimplexDomainNotReady + | ChatErrorType.Wallet | ChatErrorType.NotResolvedLocally | ChatErrorType.UnsupportedConnReq | ChatErrorType.ConnReqMessageProhibited @@ -1178,6 +1179,7 @@ export namespace ChatErrorType { | "chatStoreChanged" | "invalidConnReq" | "simplexDomainNotReady" + | "wallet" | "notResolvedLocally" | "unsupportedConnReq" | "connReqMessageProhibited" @@ -1339,6 +1341,11 @@ export namespace ChatErrorType { simplexDomainError: SimplexDomainError } + export interface Wallet extends Interface { + type: "wallet" + walletError: WalletError + } + export interface NotResolvedLocally extends Interface { type: "notResolvedLocally" } @@ -5139,6 +5146,65 @@ export interface VersionRange { maxVersion: number // int } +export type WalletError = + | WalletError.NoMaster + | WalletError.MasterExists + | WalletError.BadMnemonic + | WalletError.HiddenProfile + | WalletError.AccountBound + | WalletError.CounterUnknown + | WalletError.IndexTooLarge + | WalletError.Derivation + +export namespace WalletError { + export type Tag = + | "noMaster" + | "masterExists" + | "badMnemonic" + | "hiddenProfile" + | "accountBound" + | "counterUnknown" + | "indexTooLarge" + | "derivation" + + interface Interface { + type: Tag + } + + export interface NoMaster extends Interface { + type: "noMaster" + } + + export interface MasterExists extends Interface { + type: "masterExists" + } + + export interface BadMnemonic extends Interface { + type: "badMnemonic" + } + + export interface HiddenProfile extends Interface { + type: "hiddenProfile" + } + + export interface AccountBound extends Interface { + type: "accountBound" + } + + export interface CounterUnknown extends Interface { + type: "counterUnknown" + } + + export interface IndexTooLarge extends Interface { + type: "indexTooLarge" + } + + export interface Derivation extends Interface { + type: "derivation" + derivationError: string + } +} + export type XFTPErrorType = | XFTPErrorType.BLOCK | XFTPErrorType.SESSION diff --git a/packages/simplex-chat-python/src/simplex_chat/types/_types.py b/packages/simplex-chat-python/src/simplex_chat/types/_types.py index 25206c8654..d845849c00 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -839,6 +839,10 @@ class ChatErrorType_simplexDomainNotReady(TypedDict): simplexDomain: "SimplexDomain" simplexDomainError: "SimplexDomainError" +class ChatErrorType_wallet(TypedDict): + type: Literal["wallet"] + walletError: "WalletError" + class ChatErrorType_notResolvedLocally(TypedDict): type: Literal["notResolvedLocally"] @@ -1073,6 +1077,7 @@ ChatErrorType = ( | ChatErrorType_chatStoreChanged | ChatErrorType_invalidConnReq | ChatErrorType_simplexDomainNotReady + | ChatErrorType_wallet | ChatErrorType_notResolvedLocally | ChatErrorType_unsupportedConnReq | ChatErrorType_connReqMessageProhibited @@ -1130,7 +1135,7 @@ ChatErrorType = ( | ChatErrorType_exception ) -ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "simplexDomainNotReady", "notResolvedLocally", "unsupportedConnReq", "connReqMessageProhibited", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"] +ChatErrorType_Tag = Literal["noActiveUser", "noConnectionUser", "noSndFileUser", "noRcvFileUser", "userUnknown", "userExists", "chatRelayExists", "differentActiveUser", "cantDeleteActiveUser", "cantDeleteLastUser", "cantHideLastUser", "hiddenUserAlwaysMuted", "emptyUserPassword", "userAlreadyHidden", "userNotHidden", "invalidDisplayName", "chatNotStarted", "chatNotStopped", "chatStoreChanged", "invalidConnReq", "simplexDomainNotReady", "wallet", "notResolvedLocally", "unsupportedConnReq", "connReqMessageProhibited", "contactNotReady", "contactNotActive", "contactDisabled", "connectionDisabled", "groupUserRole", "groupMemberInitialRole", "contactIncognitoCantInvite", "groupIncognitoCantInvite", "groupContactRole", "groupDuplicateMember", "groupDuplicateMemberId", "groupNotJoined", "groupMemberNotActive", "cantBlockMemberForSelf", "groupMemberUserRemoved", "groupMemberNotFound", "groupCantResendInvitation", "groupInternal", "fileNotFound", "fileSize", "fileAlreadyReceiving", "fileCancelled", "fileCancel", "fileAlreadyExists", "fileWrite", "fileSend", "fileRcvChunk", "fileInternal", "fileImageType", "fileImageSize", "fileNotReceived", "fileNotApproved", "fallbackToSMPProhibited", "inlineFileProhibited", "invalidForward", "invalidChatItemUpdate", "invalidChatItemDelete", "hasCurrentCall", "noCurrentCall", "callContact", "directMessagesProhibited", "agentVersion", "agentNoSubResult", "commandError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"] ChatFeature = Literal["timedMessages", "fullDelete", "reactions", "voice", "files", "calls", "sessions"] @@ -3613,6 +3618,44 @@ class VersionRange(TypedDict): minVersion: int # int maxVersion: int # int +class WalletError_noMaster(TypedDict): + type: Literal["noMaster"] + +class WalletError_masterExists(TypedDict): + type: Literal["masterExists"] + +class WalletError_badMnemonic(TypedDict): + type: Literal["badMnemonic"] + +class WalletError_hiddenProfile(TypedDict): + type: Literal["hiddenProfile"] + +class WalletError_accountBound(TypedDict): + type: Literal["accountBound"] + +class WalletError_counterUnknown(TypedDict): + type: Literal["counterUnknown"] + +class WalletError_indexTooLarge(TypedDict): + type: Literal["indexTooLarge"] + +class WalletError_derivation(TypedDict): + type: Literal["derivation"] + derivationError: str + +WalletError = ( + WalletError_noMaster + | WalletError_masterExists + | WalletError_badMnemonic + | WalletError_hiddenProfile + | WalletError_accountBound + | WalletError_counterUnknown + | WalletError_indexTooLarge + | WalletError_derivation +) + +WalletError_Tag = Literal["noMaster", "masterExists", "badMnemonic", "hiddenProfile", "accountBound", "counterUnknown", "indexTooLarge", "derivation"] + class XFTPErrorType_BLOCK(TypedDict): type: Literal["BLOCK"] diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index ea54876b1a..d0fe2ee1c9 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -43,9 +43,7 @@ import Data.Set (Set) import qualified Data.Map.Strict as M import Data.Maybe (fromMaybe) import Data.String -import Data.List (foldl') import Data.Text (Text) -import qualified Data.Text as T import Data.Text.Encoding (decodeLatin1) import Data.Time (NominalDiffTime, UTCTime) import Data.Time.Clock.System (SystemTime (..), systemToUTCTime) @@ -70,7 +68,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Chat.Wallet (NameIndex) +import Simplex.Chat.Wallet (AccountIndex, WalletAddress, WalletError) import Simplex.Chat.Util (liftIOEither) import Simplex.FileTransfer.Description (FileDescriptionURI) import Simplex.Messaging.Server.Information (ServerPublicInfo) @@ -419,11 +417,13 @@ data ChatCommand | APIRejectContact {contactReqId :: Int64, notify :: Bool} | APISendServiceRequest {userId :: UserId, sendTarget :: ConnectTarget 'CMContact, requestTimeout :: Maybe NominalDiffTime, signKey :: Maybe (C.StoredPrivateKey 'C.Ed25519), request :: J.Object} | APISendServiceResponse {userId :: UserId, requestId :: AgentInvId, responseData :: J.Object} - | APIWallet - | APIWalletCreate {recoveryPhrase :: Maybe Text} - | APIWalletExportSeedMnemonic - | APIWalletExportNameSecret {nameIndex :: NameIndex} - | APIWalletDelete + | APIGetWallet + | APICreateWallet {mnemonic :: Maybe Text} + | APIBindWalletAccount {accountIndex_ :: Maybe AccountIndex} + | APIGetWalletAddress {accountIndex_ :: Maybe AccountIndex} + | APIExportWalletMnemonic + | APIExportWalletAccount {accountIndex :: AccountIndex} + | APIDeleteWallet | APISendCallInvitation ContactId CallType | SendCallInvitation ContactName CallType | APIRejectCall ContactId @@ -751,16 +751,6 @@ allowRemoteCommand = \case ExecAgentStoreSQL _ -> False _ -> True --- | Command text for a log, with any secret blanked. A secret is the last --- argument and takes the rest of the line, so blanking from its name is enough. -redactedCommand :: Text -> Text -redactedCommand s = foldl' blank s ["mnemonic=", "secret="] - where - blank t p = case T.breakOn p t of - (before, after) - | T.null after -> t - | otherwise -> before <> p <> "" - data RelayConnectionResult = RelayConnectionResult { relayMember :: GroupMember, relayError :: Maybe ChatError @@ -860,9 +850,10 @@ data ChatResponse | CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest, contact_ :: Maybe Contact} | CRServiceResponse {user :: User, responseData :: J.Object} | CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId} - | CRWallet {user :: User, walletKeyExists :: Bool, walletKeyPaths :: [(Text, Text)]} - | CRWalletSeedMnemonic {user :: User, recoveryPhrase :: Text} - | CRWalletDerivedSecret {user :: User, keyPath :: Text, address :: Text, derivedSecret :: Text} + | CRWallet {user :: User, accountIndexes_ :: Maybe [AccountIndex]} + | CRWalletMnemonic {user :: User, mnemonic :: Text} + | CRWalletAddress {user :: User, walletAddress :: WalletAddress} + | CRWalletAccountSecret {user :: User, walletAddress :: WalletAddress, secret :: Text} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool} | CRGroupsList {user :: User, groups :: [GroupInfo]} @@ -1491,6 +1482,7 @@ data ChatErrorType | CEChatStoreChanged | CEInvalidConnReq | CESimplexDomainNotReady {simplexDomain :: SimplexDomain, simplexDomainError :: SimplexDomainError} + | CEWallet {walletError :: WalletError} | CENotResolvedLocally -- a name or link is not a known chat in the local store and online resolution is off (PRMNever) | CEUnsupportedConnReq | CEInvalidChatMessage {connection :: Connection, msgMeta :: Maybe MsgMetaJSON, messageData :: Text, message :: String} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 630dd9a763..16644ab8fa 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -58,8 +58,8 @@ import qualified Data.UUID.V4 as V4 import Simplex.Chat.Library.Subscriber import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), badgeServerCredential, maxXFTPFileSize, mkBadgeStatus, verifyCredential) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) -import Simplex.Chat.Store.Wallets (createSeed, deleteSeed, getDeviceSeed, getNextNameIndex) -import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), deriveNameKey, importRecoveryKey, nameKeySecret, newSeed, recoveryKeyPhrase, renderNameKeyPath, seedMaster) +import Simplex.Chat.Store.Wallets (accountHeldByOther, bindAccount, createWalletSeed, deleteWalletSeed, getNextAccountIndex, getUserAccounts, getWalletSeed) +import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress (..), WalletError (..), WalletSeed (..), accountSecret, checkAccountIndex, deriveAccountKey, entropyFromMnemonic, newSeedEntropy, renderAccountPath, seedMaster, seedMnemonic) import Simplex.Messaging.Eth.Address (addressFromPrivateKey) import Simplex.Chat.Call import Simplex.Chat.Controller @@ -107,7 +107,6 @@ import qualified Simplex.Messaging.Agent.Store.DB as DB 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 Simplex.Messaging.Crypto.BIP39 (MnemonicStrength (..)) import qualified Simplex.Messaging.Crypto.ShortLink as SL import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -1492,30 +1491,39 @@ processChatCommand cxt nm = \case let AgentInvId invId = requestId connId <- withAgent $ \a -> sendServiceReplyAsync a "" (aUserId user) invId (LB.toStrict $ J.encode responseData) pure $ CRServiceReplyAccepted user (AgentConnId connId) - APIWallet -> withUser $ \user -> do - withFastStore' getDeviceSeed >>= \case - Nothing -> pure $ CRWallet user False [] - Just seed -> do - next <- withFastStore' $ \db -> getNextNameIndex db (wsId seed) - CRWallet user True <$> nameKeyRows seed next - APIWalletCreate phrase_ -> withUser $ \_ -> do - entropy <- case phrase_ of - Nothing -> asks random >>= atomically . newSeed MS256 - Just phrase -> either (const $ throwCmdError "bad recovery phrase") pure $ importRecoveryKey (encodeUtf8 phrase) - created <- withFastStore' $ \db -> createSeed db entropy - unless created $ throwCmdError "this device already has a wallet key" - processChatCommand cxt nm APIWallet - APIWalletExportSeedMnemonic -> withUser $ \user -> do - seed <- deviceSeed - phrase <- either throwCmdError pure $ recoveryKeyPhrase seed - pure $ CRWalletSeedMnemonic user (safeDecodeUtf8 phrase) - APIWalletExportNameSecret nameIdx -> withUser $ \user -> do - seed <- deviceSeed - k <- either throwCmdError pure $ seedMaster seed >>= \m -> deriveNameKey m nameIdx - pure $ CRWalletDerivedSecret user (renderNameKeyPath nameIdx) (decodeLatin1 . strEncode $ addressFromPrivateKey k) (safeDecodeUtf8 $ nameKeySecret k) - APIWalletDelete -> withUser $ \_ -> do - seed <- deviceSeed - withFastStore' $ \db -> deleteSeed db (wsId seed) + APIGetWallet -> withUser $ \user@User {userId} -> + CRWallet user <$> withFastStore' (\db -> getWalletSeed db $>>= \WalletSeed {wsId} -> Just <$> getUserAccounts db wsId userId) + APICreateWallet mnemonic_ -> withUser $ \_ -> do + -- a generated seed has taken no accounts; an imported one does not say how + -- many it has taken, and only a scan of the chain can tell + (entropy, nextAccount) <- case mnemonic_ of + Nothing -> (,Just 0) <$> (asks random >>= atomically . newSeedEntropy) + Just phrase -> (,Nothing) <$> liftWallet (entropyFromMnemonic $ encodeUtf8 phrase) + created <- withFastStore' $ \db -> createWalletSeed db entropy nextAccount + unless created $ throwWalletError WEMasterExists + processChatCommand cxt nm APIGetWallet + APIBindWalletAccount accountIdx_ -> withUser $ \User {userId, viewPwdHash} -> do + seed <- walletSeed + when (isJust viewPwdHash) $ throwWalletError WEHiddenProfile + _ <- liftWallet =<< withFastStore' (\db -> bindAccount db (wsId seed) userId accountIdx_) + processChatCommand cxt nm APIGetWallet + APIGetWalletAddress accountIdx_ -> withUser $ \user -> do + seed <- walletSeed + n <- resolveAccount seed accountIdx_ + CRWalletAddress user . accountAddress n <$> accountKey seed n + APIExportWalletMnemonic -> withUser $ \user -> + CRWalletMnemonic user <$> (liftWallet . seedMnemonic =<< walletSeed) + APIExportWalletAccount accountIdx -> withUser $ \user@User {userId} -> do + seed <- walletSeed + n <- resolveAccount seed (Just accountIdx) + -- a key another profile holds is not this profile's to hand out + heldByOther <- withFastStore' $ \db -> accountHeldByOther db (wsId seed) userId n + when heldByOther $ throwWalletError WEAccountBound + k <- accountKey seed n + pure $ CRWalletAccountSecret user (accountAddress n k) (accountSecret k) + APIDeleteWallet -> withUser $ \_ -> do + seed <- walletSeed + withFastStore' $ \db -> deleteWalletSeed db (wsId seed) ok_ APISendCallInvitation contactId callType -> withUser $ \user -> do -- party initiating call @@ -5453,17 +5461,32 @@ withExpirationDate globalTTL chatItemTTL action = do let ttl = fromMaybe globalTTL chatItemTTL when (ttl > 0) $ action $ addUTCTime (-1 * fromIntegral ttl) currentTs -walletNamesShown :: Int -walletNamesShown = 2 +walletSeed :: CM WalletSeed +walletSeed = withFastStore' getWalletSeed >>= maybe (throwWalletError WENoMaster) pure -deviceSeed :: CM WalletSeed -deviceSeed = withFastStore' getDeviceSeed >>= maybe (throwCmdError "no wallet key on this device") pure +throwWalletError :: WalletError -> CM a +throwWalletError = throwChatError . CEWallet -nameKeyRows :: WalletSeed -> NameIndex -> CM [(Text, Text)] -nameKeyRows seed next = either throwCmdError pure $ do - master <- seedMaster seed - forM (take walletNamesShown [next ..]) $ \nm -> - (renderNameKeyPath nm,) . decodeLatin1 . strEncode . addressFromPrivateKey <$> deriveNameKey master nm +liftWallet :: Either WalletError a -> CM a +liftWallet = either throwWalletError pure + +-- | The account a command names, or the next free one when it names none. +-- Refuses any index BIP-32 cannot harden, the counter's included. +resolveAccount :: WalletSeed -> Maybe AccountIndex -> CM AccountIndex +resolveAccount seed accountIdx_ = do + n <- maybe nextFreeAccount pure accountIdx_ + n <$ liftWallet (checkAccountIndex n) + where + nextFreeAccount = + withFastStore' (\db -> getNextAccountIndex db (wsId seed)) + >>= maybe (throwWalletError WECounterUnknown) pure + +accountKey :: WalletSeed -> AccountIndex -> CM AccountKey +accountKey seed n = liftWallet $ seedMaster seed >>= (`deriveAccountKey` n) + +accountAddress :: AccountIndex -> AccountKey -> WalletAddress +accountAddress n k = + WalletAddress {accountIndex = n, keyPath = renderAccountPath n, address = decodeLatin1 . strEncode $ addressFromPrivateKey k} chatCommandP :: Parser ChatCommand chatCommandP = @@ -5583,12 +5606,16 @@ chatCommandP = "/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)), "/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP), "/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP), - "/_wallet create new" $> APIWalletCreate Nothing, - "/_wallet create mnemonic=" *> (APIWalletCreate . Just <$> textP), - "/_wallet export name " *> (APIWalletExportNameSecret <$> keyIndexP), - "/_wallet export" $> APIWalletExportSeedMnemonic, - "/_wallet delete" $> APIWalletDelete, - "/_wallet" $> APIWallet, + "/_wallet create new" $> APICreateWallet Nothing, + "/_wallet create mnemonic=" *> (APICreateWallet . Just <$> textP), + "/_wallet bind account=" *> (APIBindWalletAccount . Just <$> accountIndexP), + "/_wallet bind" $> APIBindWalletAccount Nothing, + "/_wallet address account=" *> (APIGetWalletAddress . Just <$> accountIndexP), + "/_wallet address" $> APIGetWalletAddress Nothing, + "/_wallet export master" $> APIExportWalletMnemonic, + "/_wallet export account " *> (APIExportWalletAccount <$> accountIndexP), + "/_wallet delete" $> APIDeleteWallet, + "/_wallet" $> APIGetWallet, "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP), "/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType), "/_call reject @" *> (APIRejectCall <$> A.decimal), @@ -6129,12 +6156,12 @@ chatCommandP = quotedP = safeDecodeUtf8 <$> (A.char '"' *> A.takeTill (== '"') <* A.char '"') text1P = safeDecodeUtf8 <$> A.takeTill (== ' ') char_ = optional . A.char - -- BIP-32 hardens at 2^31, and Word32 would wrap. Digits are counted before - -- they are read, as reading a very long number is not free. - keyIndexP = do + -- Digits are counted before they are read, as reading a very long number is + -- not free. The hardening bound is a typed error when the command runs. + accountIndexP = do ds <- A.takeWhile1 isDigit let i = read (B.unpack ds) :: Integer - if B.length ds <= 10 && i < 0x80000000 then pure (fromIntegral i) else fail "key index too large" + if B.length ds <= 10 && i <= toInteger (maxBound :: AccountIndex) then pure (fromIntegral i) else fail "account index too large" displayNameP :: Parser Text displayNameP = safeDecodeUtf8 <$> displayNameP_ diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index d1203493e5..4ccc97d816 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -552,7 +552,9 @@ liftRC = liftError (ChatErrorRemoteCtrl . RCEProtocolError) handleSend :: (ByteString -> Int -> CM' (Either ChatError ChatResponse)) -> Text -> Int -> CM' RemoteResponse handleSend execCC command retryNum = do - logDebug $ "Send: " <> tshow (redactedCommand command) + -- only the verb: the rest of the line can carry a mnemonic, and blanking it by + -- substring was guesswork + logDebug $ "Send: " <> T.takeWhile (/= ' ') command -- execCC is execChatCommand CSRemoteCtrl, which checks allowRemoteCommand -- convert errors thrown in execCC into error responses to prevent aborting the protocol wrapper RRChatResponse . eitherToResult <$> execCC (encodeUtf8 command) retryNum diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs index 7dd970d8f6..03cb0bc027 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs @@ -11,20 +11,34 @@ m20260908_wallet_seeds = [r| CREATE TABLE wallet_seeds ( wallet_seed_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - entropy BYTEA NOT NULL, + entropy BYTEA NOT NULL CHECK (length(entropy) = 32), -- see the SQLite migration - next_name_index BIGINT NOT NULL DEFAULT 1, - -- one seed per device for now + next_account_index BIGINT CHECK (next_account_index BETWEEN 0 AND 2147483648), single_seed SMALLINT NOT NULL DEFAULT 1 ); +CREATE TABLE wallet_accounts ( + wallet_account_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + wallet_seed_id BIGINT NOT NULL REFERENCES wallet_seeds ON DELETE CASCADE, + account_index BIGINT CHECK (account_index BETWEEN 0 AND 2147483647), + user_id BIGINT REFERENCES users ON DELETE SET NULL +); + CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON wallet_seeds(single_seed); +CREATE UNIQUE INDEX idx_wallet_accounts_index ON wallet_accounts(wallet_seed_id, account_index); +CREATE INDEX idx_wallet_accounts_user ON wallet_accounts(user_id); |] down_m20260908_wallet_seeds :: Text down_m20260908_wallet_seeds = [r| +DROP INDEX idx_wallet_accounts_user; + +DROP INDEX idx_wallet_accounts_index; + DROP INDEX idx_wallet_seeds_single_seed; +DROP TABLE wallet_accounts; + DROP TABLE wallet_seeds; |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs index 9f3b0468c9..9a289e6ea3 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs @@ -10,21 +10,33 @@ m20260908_wallet_seeds = [sql| CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, - entropy BLOB NOT NULL, - -- known issue: after an import this starts at 1, so it can hand out a name - -- key at a path that already owns a name - next_name_index INTEGER NOT NULL DEFAULT 1, - -- one seed per device for now + entropy BLOB NOT NULL CHECK (length(entropy) = 32), -- BIP-39 entropy, 24 words + next_account_index INTEGER CHECK (next_account_index BETWEEN 0 AND 2147483648), 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), + 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_index ON wallet_accounts(wallet_seed_id, account_index); +CREATE INDEX idx_wallet_accounts_user ON wallet_accounts(user_id); |] down_m20260908_wallet_seeds :: Query down_m20260908_wallet_seeds = [sql| +DROP INDEX idx_wallet_accounts_user; + +DROP INDEX idx_wallet_accounts_index; + DROP INDEX idx_wallet_seeds_single_seed; +DROP TABLE wallet_accounts; + DROP TABLE wallet_seeds; |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index a3c86389db..65d0c73032 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -854,13 +854,16 @@ CREATE TABLE rcv_roster_transfers( ) STRICT; CREATE TABLE wallet_seeds( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, - entropy BLOB NOT NULL, - -- known issue: after an import this starts at 1, so it can hand out a name - -- key at a path that already owns a name - next_name_index INTEGER NOT NULL DEFAULT 1, - -- one seed per device for now + entropy BLOB NOT NULL CHECK(length(entropy) = 32), -- BIP-39 entropy, 24 words + next_account_index INTEGER CHECK(next_account_index BETWEEN 0 AND 2147483648), 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), + user_id INTEGER REFERENCES users ON DELETE SET NULL +) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -1396,6 +1399,11 @@ CREATE INDEX idx_chat_items_item_signed_by_group_member_id ON chat_items( item_signed_by_group_member_id ); CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON wallet_seeds(single_seed); +CREATE UNIQUE INDEX idx_wallet_accounts_index ON wallet_accounts( + wallet_seed_id, + account_index +); +CREATE INDEX idx_wallet_accounts_user ON wallet_accounts(user_id); CREATE TRIGGER on_group_members_insert_update_summary AFTER INSERT ON group_members FOR EACH ROW diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index a9a098b96c..9f9a77a8d3 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -1,49 +1,147 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE LambdaCase #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE TypeApplications #-} +-- | The device seed, and which chat profile each account belongs to. module Simplex.Chat.Store.Wallets - ( getDeviceSeed, - getNextNameIndex, - createSeed, - deleteSeed, + ( getWalletSeed, + createWalletSeed, + deleteWalletSeed, + getNextAccountIndex, + getUserAccounts, + accountHeldByOther, + bindAccount, ) where +import Control.Monad (join, when) +import Control.Monad.Except +import Control.Monad.IO.Class (liftIO) +import qualified Data.ByteArray as BA import Data.ByteString (ByteString) import Data.Int (Int64) -import Simplex.Chat.Wallet (NameIndex, SeedId, WalletSeed (..)) +import Simplex.Chat.Wallet (AccountIndex, SeedId, WalletError (..), WalletSeed (..), checkAccountIndex) +import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.DB as DB #if defined(dbPostgres) import Database.PostgreSQL.Simple (Only (..)) +import Database.PostgreSQL.Simple.SqlQQ (sql) #else import Database.SQLite.Simple (Only (..)) +import Database.SQLite.Simple.QQ (sql) #endif toSeed :: (Int64, ByteString) -> WalletSeed -toSeed (sId, seed) = WalletSeed {wsId = sId, wsEntropy = seed} +toSeed (sId, entropy) = WalletSeed {wsId = sId, wsEntropy = BA.convert entropy} -getDeviceSeed :: DB.Connection -> IO (Maybe WalletSeed) -getDeviceSeed db = +getWalletSeed :: DB.Connection -> IO (Maybe WalletSeed) +getWalletSeed db = maybeFirstRow toSeed $ DB.query_ db "SELECT wallet_seed_id, entropy FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1" --- | The index the next name bought on this device takes. -getNextNameIndex :: DB.Connection -> SeedId -> IO NameIndex -getNextNameIndex db sId = - maybe 1 (fromIntegral :: Int64 -> NameIndex) - <$> ( maybeFirstRow fromOnly $ - DB.query db "SELECT next_name_index FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) - ) - --- | False if the device already has a seed. -createSeed :: DB.Connection -> ByteString -> IO Bool -createSeed db entropy = - getDeviceSeed db >>= \case +-- | False if the device already has a seed. The counter is 'Nothing' for an +-- imported phrase, which does not say how many accounts it has been used for. +createWalletSeed :: DB.Connection -> BA.ScrubbedBytes -> Maybe AccountIndex -> IO Bool +createWalletSeed db entropy nextAccount = + getWalletSeed db >>= \case Just _ -> pure False - Nothing -> True <$ DB.execute db "INSERT INTO wallet_seeds (entropy) VALUES (?)" (Only $ DB.Binary entropy) + Nothing -> + True + <$ DB.execute + db + "INSERT INTO wallet_seeds (entropy, next_account_index) VALUES (?, ?)" + (DB.Binary (BA.convert entropy :: ByteString), accountIndexCol <$> nextAccount) -deleteSeed :: DB.Connection -> SeedId -> IO () -deleteSeed db sId = DB.execute db "DELETE FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) +deleteWalletSeed :: DB.Connection -> SeedId -> IO () +deleteWalletSeed db sId = DB.execute db "DELETE FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) + +-- | The index the next account takes. Nothing after an import, where the phrase +-- does not say how many accounts it has been used for. +getNextAccountIndex :: DB.Connection -> SeedId -> IO (Maybe AccountIndex) +getNextAccountIndex db sId = + fmap (fromIntegral @Int64) . join + <$> maybeFirstRow fromOnly (DB.query db "SELECT next_account_index FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId)) + +-- | The accounts a profile holds, in index order. A profile holds as many as it +-- owns names. +getUserAccounts :: DB.Connection -> SeedId -> UserId -> IO [AccountIndex] +getUserAccounts db sId userId = + map (fromIntegral @Int64 . fromOnly) + <$> DB.query + db + [sql| + SELECT account_index FROM wallet_accounts + WHERE wallet_seed_id = ? AND user_id = ? AND account_index IS NOT NULL + ORDER BY account_index + |] + (sId, userId) + +-- | Which profile holds an account: 'Nothing' when the device does not know the +-- account at all, @Just Nothing@ when it knows it and no profile holds it. +accountUser :: DB.Connection -> SeedId -> AccountIndex -> IO (Maybe (Maybe Int64)) +accountUser db sId n = + maybeFirstRow (fromOnly @(Maybe Int64)) $ + DB.query db "SELECT user_id FROM wallet_accounts WHERE wallet_seed_id = ? AND account_index = ?" (sId, accountIndexCol n) + +-- | True when a profile other than this one holds the account, which is what +-- keeps one profile from exporting another profile's key. An account no profile +-- holds is not another profile's. +heldByOther :: UserId -> Maybe (Maybe Int64) -> Bool +heldByOther userId = \case + Just (Just heldBy) -> heldBy /= userId + _ -> False + +accountHeldByOther :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bool +accountHeldByOther db sId userId n = heldByOther userId <$> accountUser db sId n + +-- | Bind an account to a profile: one the device already knows about, one a +-- scan found, or the next free one when no index is given. Reading the counter +-- and taking the account happen in one transaction, so two binds racing cannot +-- both take it. +bindAccount :: DB.Connection -> SeedId -> UserId -> Maybe AccountIndex -> IO (Either WalletError AccountIndex) +bindAccount db sId userId accountIdx_ = runExceptT $ do + n <- maybe nextFreeAccount pure accountIdx_ + liftEither $ checkAccountIndex n + held <- liftIO $ accountUser db sId n + when (heldByOther userId held) $ throwError WEAccountBound + liftIO $ do + case held of + Just (Just _) -> pure () -- already this profile's + Just Nothing -> setAccountUser db sId userId n + Nothing -> insertAccount db sId userId n + raiseNextAccount db sId n + pure n + where + nextFreeAccount = ExceptT $ maybe (Left WECounterUnknown) Right <$> getNextAccountIndex db sId + +setAccountUser :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO () +setAccountUser db sId userId n = + DB.execute + db + [sql| UPDATE wallet_accounts SET user_id = ? WHERE wallet_seed_id = ? AND account_index = ? |] + (userId, sId, accountIndexCol n) + +-- | Keep the counter a high-water mark, so an account taken by index is not +-- handed out again as the next free one. Never lowers it, and never gives a +-- value to the imported phrase that has none. +raiseNextAccount :: DB.Connection -> SeedId -> AccountIndex -> IO () +raiseNextAccount db sId n = + DB.execute + db + [sql| + UPDATE wallet_seeds SET next_account_index = ? + WHERE wallet_seed_id = ? AND next_account_index IS NOT NULL AND next_account_index <= ? + |] + (accountIndexCol n + 1, sId, accountIndexCol n) + +insertAccount :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO () +insertAccount db sId userId n = + DB.execute db "INSERT INTO wallet_accounts (wallet_seed_id, account_index, user_id) VALUES (?, ?, ?)" (sId, accountIndexCol n, userId) + +-- | How an account index is stored: the column is a signed integer. +accountIndexCol :: AccountIndex -> Int64 +accountIndexCol = fromIntegral diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index d02c1d70e4..6655b9e8b0 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -57,6 +57,7 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme +import Simplex.Chat.Wallet (WalletAddress (..), WalletError (..)) import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.Messaging.Agent (DatabaseDiff (..)) import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..)) @@ -188,11 +189,13 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te CRContactRequestRejected u UserContactRequest {localDisplayName = c} _ct_ -> ttyUser u [ttyContact c <> ": contact request rejected"] CRServiceResponse u resp -> ttyUser u ["service response: " <> viewJSON resp] CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)] - CRWallet u exists paths -> ttyUser u $ if exists then map nameRow paths else ["no wallet key"] - where - nameRow (path, addr) = plain $ path <> " " <> addr - CRWalletSeedMnemonic u phrase -> ttyUser u [plain phrase] - CRWalletDerivedSecret u path addr secret -> ttyUser u [plain $ path <> " " <> addr <> " " <> secret] + CRWallet u accounts_ -> ttyUser u $ case accounts_ of + Nothing -> ["no wallet on this device"] + Just [] -> ["wallet, no accounts for this profile"] + Just accounts -> [plain $ "accounts: " <> T.intercalate ", " (map tshow accounts)] + CRWalletMnemonic u mnemonic -> ttyUser u [plain mnemonic] + CRWalletAddress u a -> ttyUser u [walletAddressRow a] + CRWalletAccountSecret u a secret -> ttyUser u [walletAddressRow a <> " " <> plain secret] CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreated u g _groupLink _relays -> ttyUser u $ viewGroupCreated g testView CRPublicGroupCreationFailed u results -> ttyUser u $ viewPublicGroupCreationFailed results @@ -1098,6 +1101,21 @@ viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of ContactConnection _ -> [] CInfoInvalidJSON {} -> [] +walletAddressRow :: WalletAddress -> StyledString +walletAddressRow WalletAddress {accountIndex, keyPath, address} = + plain $ tshow accountIndex <> " " <> keyPath <> " " <> address + +walletErrorText :: WalletError -> Text +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" + WEAccountBound -> "another profile holds this account" + WECounterUnknown -> "unknown how many accounts this phrase has used, a scan of the chain has to run first" + WEIndexTooLarge -> "account index is too large to harden" + WEDerivation e -> "derivation failed: " <> T.pack e + viewContactsList :: [Contact] -> [StyledString] viewContactsList = let getLDN :: Contact -> ContactName @@ -2740,6 +2758,7 @@ viewChatError isCmd logLevel testView = \case SDENoValidLink -> "has no valid connection link" SDEUnknownDomain -> "is not included in the connection link's profile" in [plain $ "SimpleX name " <> strEncode domain <> " " <> reason] + CEWallet walletErr -> [plain $ "wallet: " <> walletErrorText walletErr] CENotResolvedLocally -> ["no matching chat found, name resolution is disabled"] CEUnsupportedConnReq -> [ "", "Connection link is not supported by the your app version, please ugrade it.", plain updateStr] CEInvalidChatMessage Connection {connId} msgMeta_ msg e -> diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index d220318413..476a4d44f9 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -1,25 +1,36 @@ {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE TemplateHaskell #-} --- | BIP-39 seeds and the keys derived from them. +-- | The device wallet: one BIP-39 seed, and the accounts derived from it. -- --- One key per name. A name's secret is a leaf, so exporting it hands over that --- name only. +-- An account is a hardened BIP-44 account, @m\/44'\/60'\/n'\/0\/0@, which is how +-- Ledger Live lays out an Ethereum wallet. The account level is hardened, so an +-- exported account key hands over that account and reaches no other. +-- +-- Nothing here knows about chat profiles. Which profile an account belongs to is +-- a mapping in "Simplex.Chat.Store.Wallets". module Simplex.Chat.Wallet ( SeedId, + AccountIndex, + AccountKey, WalletSeed (..), - NameIndex, - newSeed, - importRecoveryKey, - recoveryKeyPhrase, + WalletAddress (..), + WalletError (..), + newSeedEntropy, + entropyFromMnemonic, + seedMnemonic, seedMaster, - deriveNameKey, - renderNameKeyPath, - nameKeySecret, + renderAccountPath, + deriveAccountKey, + accountSecret, + checkAccountIndex, ) where import Control.Concurrent.STM import Crypto.Random (ChaChaDRG) +import qualified Data.Aeson.TH as JQ +import qualified Data.ByteArray as BA import qualified Data.ByteArray.Encoding as BAE import Data.ByteString (ByteString) import Data.Int (Int64) @@ -30,44 +41,99 @@ import qualified Simplex.Messaging.Crypto.BIP32 as B32 import qualified Simplex.Messaging.Crypto.BIP39 as B39 import qualified Simplex.Messaging.Crypto.Secp256k1 as S import Simplex.Messaging.Eth.Address (ethereumPath) +import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) type SeedId = Int64 --- | BIP-44 address index, one per name. Names sit in account 0, from index 1: --- account 0 index 0 is left for the profile accounts to start beside. -type NameIndex = Word32 +-- | BIP-44 account index. One account owns one thing on chain, one name to +-- begin with. +type AccountIndex = Word32 +-- | The key at an account index. It owns whatever that account owns. +type AccountKey = S.PrivateKey + +-- | The device seed. The entropy is 'BA.ScrubbedBytes', so a derived 'Show' +-- does not print it. Copies made for BIP-39 are plain 'ByteString' and are not +-- wiped. data WalletSeed = WalletSeed { wsId :: SeedId, - wsEntropy :: ByteString + wsEntropy :: BA.ScrubbedBytes } - deriving (Eq) + deriving (Eq, Show) -instance Show WalletSeed where - show s = "WalletSeed " <> show (wsId s) <> " " +-- | One derived address, with the index it came from, so a caller that left the +-- index out knows what it got. +data WalletAddress = WalletAddress + { accountIndex :: AccountIndex, + keyPath :: Text, + address :: Text + } + deriving (Show) --- | No 25th-word passphrase: it would be a second secret to back up. -newSeed :: B39.MnemonicStrength -> TVar ChaChaDRG -> STM ByteString -newSeed strength g = B39.mnemonicToEntropy <$> B39.randomMnemonic strength g +data WalletError + = WENoMaster -- the device has no master entropy + | WEMasterExists -- create, when it already has one + | WEBadMnemonic -- wrong word count, wrong word, or bad checksum + | WEHiddenProfile -- bind, on a profile the app hides + | WEAccountBound -- bind or export account, on an account another profile holds + | WECounterUnknown -- no counter to read yet, after an import + | WEIndexTooLarge -- at or above 2^31 + | WEDerivation {derivationError :: String} -- BIP-32 or BIP-39 said no + deriving (Eq, Show) -importRecoveryKey :: ByteString -> Either String ByteString -importRecoveryKey phrase = B39.mnemonicToEntropy <$> B39.parseMnemonic phrase +-- | BIP-32 hardens an index by adding 2^31, so an index at or above it is +-- already a hardened component and derives the same key as the index it wraps +-- onto. Refusing it is what keeps one account index to one key. +checkAccountIndex :: AccountIndex -> Either WalletError () +checkAccountIndex n = if n >= B32.hardenedOffset then Left WEIndexTooLarge else Right () -recoveryKeyPhrase :: WalletSeed -> Either String ByteString -recoveryKeyPhrase s = B39.mnemonicPhrase <$> B39.entropyToMnemonic (wsEntropy s) +-- | 24 words. No 25th-word passphrase: it would be a second secret to back up, +-- and losing it would look exactly like losing the phrase. +masterStrength :: B39.MnemonicStrength +masterStrength = B39.MS256 + +newSeedEntropy :: TVar ChaChaDRG -> STM BA.ScrubbedBytes +newSeedEntropy g = BA.convert . B39.mnemonicToEntropy <$> B39.randomMnemonic masterStrength g + +entropyFromMnemonic :: ByteString -> Either WalletError BA.ScrubbedBytes +entropyFromMnemonic phrase = case B39.parseMnemonic phrase of + Right m | length (B39.mnemonicWords m) == B39.strengthWordCount masterStrength -> + Right . BA.convert $ B39.mnemonicToEntropy m + _ -> Left WEBadMnemonic + +seedMnemonic :: WalletSeed -> Either WalletError Text +seedMnemonic s = + bipError . fmap (decodeLatin1 . B39.mnemonicPhrase) . B39.entropyToMnemonic $ entropyBytes s -- | Deriving this runs PBKDF2, so it is done once per command. -seedMaster :: WalletSeed -> Either String B32.ExtendedKey +seedMaster :: WalletSeed -> Either WalletError B32.ExtendedKey seedMaster s = do - m <- B39.entropyToMnemonic (wsEntropy s) - B32.masterKey (B39.mnemonicToSeed m "") + m <- bipError . B39.entropyToMnemonic $ entropyBytes s + bipError . B32.masterKey $ B39.mnemonicToSeed m "" -renderNameKeyPath :: NameIndex -> Text -renderNameKeyPath nm = decodeLatin1 . B32.renderPath $ ethereumPath 0 nm +accountPath :: AccountIndex -> [Word32] +accountPath n = ethereumPath n 0 -deriveNameKey :: B32.ExtendedKey -> NameIndex -> Either String S.PrivateKey -deriveNameKey master nm = B32.xkKey <$> B32.derivePath master (ethereumPath 0 nm) +renderAccountPath :: AccountIndex -> Text +renderAccountPath = decodeLatin1 . B32.renderPath . accountPath + +deriveAccountKey :: B32.ExtendedKey -> AccountIndex -> Either WalletError AccountKey +deriveAccountKey master n = B32.xkKey <$> bipError (B32.derivePath master $ accountPath n) -- | As wallets take it when a key is imported on its own. -nameKeySecret :: S.PrivateKey -> ByteString -nameKeySecret k = "0x" <> BAE.convertToBase BAE.Base16 (S.unPrivateKey k) +accountSecret :: AccountKey -> Text +accountSecret k = "0x" <> decodeLatin1 (BAE.convertToBase BAE.Base16 $ S.unPrivateKey k) + +entropyBytes :: WalletSeed -> ByteString +entropyBytes = BA.convert . wsEntropy + +-- | The BIP-32 and BIP-39 functions report failure as a string. For entropy +-- this module produced only 'B32.masterKey' and 'B32.derivePath' can fail at +-- all, with a negligible probability, and nothing here retries, so the whole +-- family shares one constructor. +bipError :: Either String a -> Either WalletError a +bipError = either (Left . WEDerivation) Right + +$(JQ.deriveJSON defaultJSON ''WalletAddress) + +$(JQ.deriveJSON (sumTypeJSON $ dropPrefix "WE") ''WalletError) diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index be07ec1476..f42f7212dd 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -6,159 +6,283 @@ module WalletTests where import ChatClient import ChatTests.DBUtils import ChatTests.Utils +import qualified Data.ByteArray as BA +import qualified Data.ByteArray.Encoding as BAE import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B -import Data.Either (isLeft) +import Data.Either (isRight) import Data.List (nub) +import qualified Data.Text as T +import Simplex.Chat.Wallet (AccountIndex, WalletError (..), WalletSeed (..), accountSecret, deriveAccountKey, entropyFromMnemonic, renderAccountPath, seedMaster, seedMnemonic) +import qualified Simplex.Messaging.Crypto.BIP39 as B39 import qualified Simplex.Messaging.Crypto.Secp256k1 as S -import Simplex.Chat.Controller (redactedCommand) -import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), deriveNameKey, importRecoveryKey, nameKeySecret, recoveryKeyPhrase, renderNameKeyPath, seedMaster) import Simplex.Messaging.Eth.Address (addressFromPrivateKey) import Simplex.Messaging.Util (safeDecodeUtf8) import Test.Hspec hiding (it) import qualified Test.Hspec as Hspec --- | The standard BIP-39 test vector, so the addresses can be checked elsewhere. -testPhrase :: ByteString -testPhrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" +-- | The standard BIP-39 test vector, 12 words, so the addresses below can be +-- checked against any other wallet. Importing takes 24 words, so this phrase is +-- only used for derivation, never through a command. +testPhrase12 :: ByteString +testPhrase12 = B.unwords $ replicate 11 "abandon" <> ["about"] -testSeed :: WalletSeed -testSeed = WalletSeed {wsId = 1, wsEntropy = either error id $ importRecoveryKey testPhrase} +-- | The 24 word all-zero-entropy vector, the length the commands take. It is a +-- different seed from the 12 word one, so it reaches different addresses. +testPhrase24 :: ByteString +testPhrase24 = B.unwords $ replicate 23 "abandon" <> ["art"] -nameKey :: NameIndex -> Either String S.PrivateKey -nameKey nm = seedMaster testSeed >>= \m -> deriveNameKey m nm +seedFromPhrase :: ByteString -> WalletSeed +seedFromPhrase phrase = + WalletSeed {wsId = 1, wsEntropy = BA.convert . B39.mnemonicToEntropy . either error id $ B39.parseMnemonic phrase} + +accountKey :: WalletSeed -> AccountIndex -> S.PrivateKey +accountKey seed n = either (error . show) id $ seedMaster seed >>= \m -> deriveAccountKey m n walletDerivationTests :: Spec walletDerivationTests = do - Hspec.it "name keys line up with other wallets' derivation" $ do - let addrOf k = either error (show . addressFromPrivateKey) (nameKey k) - -- MetaMask accounts 2 and 3 for this phrase; account 1 is the unused m/44'/60'/0'/0/0 - addrOf 1 `shouldBe` "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" - addrOf 2 `shouldBe` "0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A" - Hspec.it "derives the same secret as other wallets" $ - -- MetaMask account 2 for this phrase, as exported by "Show private key" - either error nameKeySecret (nameKey 1) - `shouldBe` "0x9a983cb3d832fbde5ab49d692b7a8bf5b5d232479c99333d0fc8e1d21f1b55b6" - Hspec.it "renders the path a name key sits at" $ do - renderNameKeyPath 1 `shouldBe` "m/44'/60'/0'/0/1" - renderNameKeyPath 7 `shouldBe` "m/44'/60'/0'/0/7" + Hspec.it "accounts are the accounts another wallet derives for the same phrase" $ do + let addrOf = show . addressFromPrivateKey . accountKey (seedFromPhrase testPhrase12) + -- Ledger Live accounts 1 and 2 for this phrase, the published values for it + addrOf 0 `shouldBe` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + addrOf 1 `shouldBe` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265" + Hspec.it "the exported secret is the one another wallet shows for that account" $ do + let k = accountKey (seedFromPhrase testPhrase12) 0 + -- as a wallet shows it for m/44'/60'/0'/0/0 of this phrase + accountSecret k `shouldBe` "0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727" + Hspec.it "every account has its own address" $ do + let seed = seedFromPhrase testPhrase12 + addrs = map (show . addressFromPrivateKey . accountKey seed) [0 .. 9] + length (nub addrs) `shouldBe` 10 + Hspec.it "a secret whose first byte is zero keeps its 64 hex digits" $ do + let k = either error id . S.mkPrivateKey $ B.pack ('\0' : replicate 31 '\1') + secret = T.unpack $ accountSecret k + take 4 secret `shouldBe` "0x00" + length secret `shouldBe` 66 + Hspec.it "renders the path an account sits at" $ do + renderAccountPath 0 `shouldBe` "m/44'/60'/0'/0/0" + renderAccountPath 7 `shouldBe` "m/44'/60'/7'/0/0" Hspec.it "round-trips the phrase it was imported from" $ - recoveryKeyPhrase testSeed `shouldBe` Right testPhrase - Hspec.it "refuses a phrase with a bad checksum" $ - importRecoveryKey (B.unwords $ replicate 12 "abandon") `shouldSatisfy` isLeft - Hspec.it "keeps a secret out of the log a remote host writes" $ do - redactedCommand ("/_wallet create mnemonic=" <> safeDecodeUtf8 testPhrase) - `shouldBe` "/_wallet create mnemonic=" - redactedCommand "/_wallet export name 1 secret=shibboleth" - `shouldBe` "/_wallet export name 1 secret=" - redactedCommand "/_wallet export name 1" `shouldBe` "/_wallet export name 1" + seedMnemonic (seedFromPhrase testPhrase24) `shouldBe` Right (safeDecodeUtf8 testPhrase24) + Hspec.it "takes 24 words only, with a valid checksum" $ do + entropyFromMnemonic testPhrase24 `shouldSatisfy` isRight + entropyFromMnemonic testPhrase12 `shouldBe` Left WEBadMnemonic + entropyFromMnemonic (B.unwords $ replicate 24 "abandon") `shouldBe` Left WEBadMnemonic + +testWalletHiddenProfile :: HasCallStack => TestParams -> IO () +testWalletHiddenProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, no accounts for this profile" + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/hide user my_password" + alice <## "current user alisa:" + alice <## "messages are hidden (use /tail to view)" + alice <## "profile is hidden" + alice ##> "/_wallet bind" + alice <## "wallet: a hidden profile cannot own an account" + +testWalletExportNotMine :: HasCallStack => TestParams -> IO () +testWalletExportNotMine ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, no accounts for this profile" + alice ##> "/_wallet bind" + alice <## "accounts: 0" + alice ##> "/create user alisa" + showActiveUser alice "alisa" + -- account 0 is the other profile's, and its key is not this profile's to take + alice ##> "/_wallet export account 0" + alice <## "wallet: another profile holds this account" + -- an account nobody holds is still derivable, which is what a scan needs + alice ##> "/_wallet export account 7" + row <- getTermLine alice + words row !! 1 `shouldBe` "m/44'/60'/7'/0/0" + +testWalletIndexTooLarge :: HasCallStack => TestParams -> IO () +testWalletIndexTooLarge ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, no accounts for this profile" + -- 2^31 is already a hardened component, so it would derive account 0's key + alice ##> "/_wallet address account=2147483648" + alice <## "wallet: account index is too large to harden" + alice ##> "/_wallet bind account=2147483648" + alice <## "wallet: account index is too large to harden" + alice ##> "/_wallet export account 2147483648" + alice <## "wallet: account index is too large to harden" + -- the largest index that can be hardened is usable, and the counter follows it + alice ##> "/_wallet bind account=2147483647" + alice <## "accounts: 2147483647" + alice ##> "/_wallet bind" + alice <## "wallet: account index is too large to harden" + +-- | The address a wallet reaches when the secret is imported as a private key. +addressFromSecret :: String -> String +addressFromSecret secret = + show . addressFromPrivateKey . either error id . S.mkPrivateKey . either error id $ + BAE.convertFromBase BAE.Base16 (B.drop 2 $ B.pack secret) walletTests :: SpecWith TestParams walletTests = do - it "creates no seed until asked, then derives addresses" testWalletCreate - it "the seed and the addresses come back after a restart" testWalletPersists - it "every profile sees the same names, as the seed is the device's" testWalletSharedByProfiles - it "imports a phrase, exports it, and refuses a second import" testWalletImport - it "exports the secret of any name key" testWalletExportDerivedSecret - it "deletes the seed, and a seed can be imported again" testWalletDelete - it "discards a seed imported before the database is restored" testWalletImportThenRestore - --- | The state a chat database backed up before the seed restores to. -forgetSeed :: HasCallStack => TestCC -> IO () -forgetSeed cc = cc ##> "/sql chat DELETE FROM wallet_seeds" - -nameRows :: HasCallStack => TestCC -> IO [(String, String)] -nameRows cc = mapM (\_ -> nameRow <$> getTermLine cc) [0 .. 1 :: Int] - where - nameRow l = case words l of - [path, addr] -> (path, addr) - _ -> error $ "unexpected wallet row: " <> l + it "creates no wallet until asked, and only one" testWalletCreate + it "binds the next free account, and re-binding one it holds changes nothing" testWalletBind + it "keeps each profile's accounts apart" testWalletAccountsPerProfile + it "taking the next account skips one already bound by index" testWalletBindByIndexThenNext + it "derives an address without taking it" testWalletAddress + it "exports the master phrase and one account's secret" testWalletExport + it "will not take a new account on an imported phrase" testWalletImport + it "the wallet and the accounts come back after a restart" testWalletPersists + it "deletes the wallet, and one can be made again" testWalletDelete + it "a hidden profile is bound no account" testWalletHiddenProfile + it "will not export an account another profile holds" testWalletExportNotMine + it "refuses an index BIP-32 cannot harden, on every command" testWalletIndexTooLarge testWalletCreate :: HasCallStack => TestParams -> IO () testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet" - alice <## "no wallet key" - -- asking creates nothing + alice <## "no wallet on this device" + -- reading creates nothing alice ##> "/_wallet" - alice <## "no wallet key" - alice ##> "/_wallet export" - alice <## "bad chat command: no wallet key on this device" + alice <## "no wallet on this device" + alice ##> "/_wallet export master" + alice <## "wallet: this device has no wallet" alice ##> "/_wallet create new" - rows <- nameRows alice - map fst rows `shouldBe` ["m/44'/60'/0'/0/1", "m/44'/60'/0'/0/2"] - length (nub $ map snd rows) `shouldBe` 2 - -- create is for the seed, and this device has one + alice <## "wallet, no accounts for this profile" alice ##> "/_wallet create new" - alice <## "bad chat command: this device already has a wallet key" + alice <## "wallet: this device already has a wallet" + alice ##> "/_wallet delete" + alice <## "ok" + -- a mistyped phrase says nothing about which word was wrong + alice ##> ("/_wallet create mnemonic=" <> B.unpack (B.unwords $ replicate 24 "abandon")) + alice <## "wallet: not a valid 24 word recovery phrase" -testWalletPersists :: HasCallStack => TestParams -> IO () -testWalletPersists ps = do - rows <- withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> "/_wallet create new" - nameRows alice - -- same database, new session: a name bought at that address must stay reachable - withTestChat ps "alice" $ \alice -> do - alice ##> "/_wallet" - rows' <- nameRows alice - rows' `shouldBe` rows +testWalletBind :: HasCallStack => TestParams -> IO () +testWalletBind ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, no accounts for this profile" + alice ##> "/_wallet bind" + alice <## "accounts: 0" + -- a profile owns as many accounts as it owns names + alice ##> "/_wallet bind" + alice <## "accounts: 0, 1" + -- binding one it already holds changes nothing + alice ##> "/_wallet bind account=0" + alice <## "accounts: 0, 1" -testWalletSharedByProfiles :: HasCallStack => TestParams -> IO () -testWalletSharedByProfiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase) - rows <- nameRows alice +testWalletAccountsPerProfile :: HasCallStack => TestParams -> IO () +testWalletAccountsPerProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, no accounts for this profile" + alice ##> "/_wallet bind" + alice <## "accounts: 0" alice ##> "/create user alisa" showActiveUser alice "alisa" - -- the seed belongs to the device, so a name is not a profile's to see or not + -- the wallet is the device's, the accounts are the profile's alice ##> "/_wallet" - rows' <- nameRows alice - rows' `shouldBe` rows - alice ##> "/_wallet export" - alice <## B.unpack testPhrase + alice <## "wallet, no accounts for this profile" + alice ##> "/_wallet bind" + alice <## "accounts: 1" + alice ##> "/_wallet bind account=0" + alice <## "wallet: another profile holds this account" + +testWalletBindByIndexThenNext :: HasCallStack => TestParams -> IO () +testWalletBindByIndexThenNext ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, no accounts for this profile" + -- an account a scan found, bound by index, is still taken + alice ##> "/_wallet bind account=2" + alice <## "accounts: 2" + alice ##> "/_wallet bind" + alice <## "accounts: 2, 3" + -- accounts are listed by index, not by the order they were bound + alice ##> "/_wallet bind account=1" + alice <## "accounts: 1, 2, 3" + -- and binding a low index never moves the counter back onto an account held + alice ##> "/_wallet bind" + alice <## "accounts: 1, 2, 3, 4" + +testWalletAddress :: HasCallStack => TestParams -> IO () +testWalletAddress ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, no accounts for this profile" + -- reading the next free account does not take it + alice ##> "/_wallet address" + addr <- getTermLine alice + alice ##> "/_wallet address" + addr' <- getTermLine alice + addr' `shouldBe` addr + words addr !! 1 `shouldBe` "m/44'/60'/0'/0/0" + alice ##> "/_wallet address account=3" + at3 <- getTermLine alice + words at3 !! 1 `shouldBe` "m/44'/60'/3'/0/0" + -- a malformed index is a parse error, never a silent bind of the next account + alice ##> "/_wallet bind account=abc" + alice <## "bad chat command: Failed reading: empty" + alice ##> "/_wallet" + alice <## "wallet, no accounts for this profile" + -- an index BIP-32 cannot harden is refused rather than folded onto a low one + alice ##> "/_wallet address account=2147483648" + alice <## "wallet: account index is too large to harden" + +testWalletExport :: HasCallStack => TestParams -> IO () +testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase24) + alice <## "wallet, no accounts for this profile" + alice ##> "/_wallet export master" + alice <## B.unpack testPhrase24 + alice ##> "/_wallet export account 0" + row <- getTermLine alice + case words row of + [idx, path, address, secret] -> do + idx `shouldBe` "0" + path `shouldBe` "m/44'/60'/0'/0/0" + -- m/44'/60'/0'/0/0 of the 24 word vector, pinned outside this + -- implementation, so a change of path fails here rather than shipping + address `shouldBe` "0xF278cF59F82eDcf871d630F28EcC8056f25C1cdb" + addressFromSecret secret `shouldBe` address + _ -> expectationFailure $ "unexpected export row: " <> row testWalletImport :: HasCallStack => TestParams -> IO () testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase) - alice <## "m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" - alice <## "m/44'/60'/0'/0/2 0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A" - alice ##> "/_wallet export" - alice <## B.unpack testPhrase - alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase) - alice <## "bad chat command: this device already has a wallet key" - -- a mistyped phrase says nothing about which word was wrong - alice ##> ("/_wallet create mnemonic=" <> B.unpack (B.unwords $ replicate 12 "abandon")) - alice <## "bad chat command: bad recovery phrase" + alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase24) + alice <## "wallet, no accounts for this profile" + -- the phrase does not say how many accounts it has been used for + alice ##> "/_wallet bind" + alice <## "wallet: unknown how many accounts this phrase has used, a scan of the chain has to run first" + alice ##> "/_wallet address" + alice <## "wallet: unknown how many accounts this phrase has used, a scan of the chain has to run first" + -- binding an account a scan found is what a restored device does + alice ##> "/_wallet bind account=4" + alice <## "accounts: 4" + -- and the counter stays unknown, because the phrase still does not say + alice ##> "/_wallet bind" + alice <## "wallet: unknown how many accounts this phrase has used, a scan of the chain has to run first" -testWalletExportDerivedSecret :: HasCallStack => TestParams -> IO () -testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase) - _ <- nameRows alice - alice ##> "/_wallet export name 1" - alice <## "m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0 0x9a983cb3d832fbde5ab49d692b7a8bf5b5d232479c99333d0fc8e1d21f1b55b6" - -- a secret whose first byte is zero keeps its 64 hex digits - alice ##> "/_wallet export name 15" - alice <## "m/44'/60'/0'/0/15 0xa25d37554EB084969C85362f7E6B1A6108e51d0e 0x009a1ccd9c667416d9db6246a35d022b1799517c0cd8547bb07ce280c119ae3c" - -- an index BIP-32 cannot reach is rejected, not wrapped into another key - alice ##> "/_wallet export name 4294967296" - alice <## "bad chat command: Failed reading: empty" +testWalletPersists :: HasCallStack => TestParams -> IO () +testWalletPersists ps = do + phrase <- withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create new" + alice <## "wallet, no accounts for this profile" + alice ##> "/_wallet bind account=2" + alice <## "accounts: 2" + alice ##> "/_wallet export master" + getTermLine alice + -- same database, new session: an account holding a name must stay reachable + withTestChat ps "alice" $ \alice -> do + alice ##> "/_wallet" + alice <## "accounts: 2" + alice ##> "/_wallet export master" + alice <## phrase testWalletDelete :: HasCallStack => TestParams -> IO () testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase) - _ <- nameRows alice + alice ##> "/_wallet create new" + alice <## "wallet, no accounts for this profile" + alice ##> "/_wallet bind account=1" + alice <## "accounts: 1" alice ##> "/_wallet delete" alice <## "ok" - alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase) - _ <- nameRows alice - pure () - -testWalletImportThenRestore :: HasCallStack => TestParams -> IO () -testWalletImportThenRestore ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase) - _ <- nameRows alice - -- restoring the database replaces the seed with what the backup held, which is nothing - forgetSeed alice alice ##> "/_wallet" - alice <## "no wallet key" - alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase) - rows <- nameRows alice - map fst rows `shouldBe` ["m/44'/60'/0'/0/1", "m/44'/60'/0'/0/2"] + alice <## "no wallet on this device" + -- the accounts went with the entropy they were counted against + alice ##> "/_wallet create new" + alice <## "wallet, no accounts for this profile"