/goal iteration 4 - unconfirmed

This commit is contained in:
Alain Brenzikofer
2026-09-21 14:42:34 +02:00
parent 4f21e68972
commit bfc4e1fbaa
17 changed files with 798 additions and 280 deletions
+37
View File
@@ -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
+7 -5
View File
@@ -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",
+3 -2
View File
@@ -213,7 +213,8 @@ undocumentedResponses =
"CRUserServersValidation",
"CRVersionInfo",
"CRWallet",
"CRWalletDerivedSecret",
"CRWalletSeedMnemonic",
"CRWalletAccountSecret",
"CRWalletAddress",
"CRWalletMnemonic",
"CRWelcome"
]
+5
View File
@@ -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
+16 -14
View File
@@ -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=<n>` 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=<n>` 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=<n>` 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.
@@ -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
@@ -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"]
+13 -21
View File
@@ -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 <> "<redacted>"
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}
+73 -46
View File
@@ -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_
+3 -1
View File
@@ -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
@@ -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;
|]
@@ -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;
|]
@@ -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
+121 -23
View File
@@ -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
+24 -5
View File
@@ -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 ->
+99 -33
View File
@@ -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) <> " <redacted>"
-- | 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)
+240 -116
View File
@@ -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=<redacted>"
redactedCommand "/_wallet export name 1 secret=shibboleth"
`shouldBe` "/_wallet export name 1 secret=<redacted>"
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"