From c11d0318c4001a63c9667929e4167839ac18e120 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 8 Sep 2026 15:30:13 +0000 Subject: [PATCH 01/67] core: wallet key for name ownership (CLI only) A name has to be owned by an address the client can still derive after a restart or on a new device. This adds that key and nothing else. /wallet create makes one BIP-39 key per device and one BIP-44 account per chat profile under it, /wallet shows the address that would own the next name that profile buys, /wallet import and /wallet export move the key with its recovery phrase. A name key sits at m/44'/60'/'/0/, which is ordinary BIP-44, so the phrase reaches the same addresses in other wallets. No signing, so nothing can be bought or edited yet. Split out of #7390 / #7425. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- CHANGELOG.md | 12 ++ bots/src/API/Docs/Commands.hs | 4 + bots/src/API/Docs/Responses.hs | 2 + cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 5 + src/Simplex/Chat/Controller.hs | 9 +- src/Simplex/Chat/Help.hs | 23 ++- src/Simplex/Chat/Library/Commands.hs | 36 +++++ src/Simplex/Chat/Store/Postgres/Migrations.hs | 4 +- .../Migrations/M20260908_wallet_seeds.hs | 35 +++++ .../Store/Postgres/Migrations/chat_schema.sql | 37 ++++- src/Simplex/Chat/Store/SQLite/Migrations.hs | 4 +- .../Migrations/M20260908_wallet_seeds.hs | 42 ++++++ .../Store/SQLite/Migrations/chat_schema.sql | 14 +- src/Simplex/Chat/Store/Wallets.hs | 109 +++++++++++++++ src/Simplex/Chat/View.hs | 16 +++ src/Simplex/Chat/Wallet.hs | 131 ++++++++++++++++++ tests/Test.hs | 3 + tests/WalletTests.hs | 118 ++++++++++++++++ 20 files changed, 600 insertions(+), 8 deletions(-) create mode 100644 src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs create mode 100644 src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs create mode 100644 src/Simplex/Chat/Store/Wallets.hs create mode 100644 src/Simplex/Chat/Wallet.hs create mode 100644 tests/WalletTests.hs diff --git a/CHANGELOG.md b/CHANGELOG.md index 8487f0a53c..fa98875d6f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,17 @@ # Release History +## Unreleased + +Wallet (in development, CLI only). A key on the device, so that a name bought +later has an owner the app can still derive: +- `/wallet create` creates one BIP-39 key per device and one BIP-44 account per + chat profile under it, `/wallet` shows the address that would own the next + name that profile buys, `/wallet import` and `/wallet export` move the key + with its recovery phrase. +- A name key sits at `m/44'/60'/'/0/`, which is ordinary BIP-44, + so the phrase reaches the same addresses in other wallets. +- No signing, so nothing can be bought or edited yet. + ## v6.5 30 April, 2026 diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index d126ff1844..c875c3f5e6 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -450,6 +450,10 @@ undocumentedCommands = "APIVerifyContactDomain", "APIVerifyGroupMember", "APIVerifyToken", + "APIWallet", + "APIWalletCreate", + "APIWalletExport", + "APIWalletImport", "CheckChatRunning", "ConfirmRemoteCtrl", "ConnectRemoteCtrl", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 76f1ddb76b..913e57b432 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -212,5 +212,7 @@ undocumentedResponses = "CRUserServers", "CRUserServersValidation", "CRVersionInfo", + "CRWallet", + "CRWalletPhrase", "CRWelcome" ] diff --git a/cabal.project b/cabal.project index 9c55ad7677..726e86fdbf 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 2d4b40e10475fd2d09c76c590df89f384ac45c85 + tag: b23d3a3265b4511afbaab8d7caa1528a662df6e0 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 00fd3f7c5d..79eedf458c 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."2d4b40e10475fd2d09c76c590df89f384ac45c85" = "04jr59xqs5lc193w761di3bpnxwz6638yg4yvcmgq5m4z5djj8jk"; + "https://github.com/simplex-chat/simplexmq.git"."b23d3a3265b4511afbaab8d7caa1528a662df6e0" = "1kybaa4bb2jbh3jiaacxgvaixka6z9nvgxgvdm09jsk2w8r9h90x"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 3c50d92968..7c7a253042 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -41,6 +41,8 @@ library Simplex.Chat.Badges Simplex.Chat.Badges.CLI Simplex.Chat.Names + Simplex.Chat.Store.Wallets + Simplex.Chat.Wallet Simplex.Chat.Call Simplex.Chat.Controller Simplex.Chat.Delivery @@ -156,6 +158,7 @@ library Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link Simplex.Chat.Store.Postgres.Migrations.M20260828_file_expiry + Simplex.Chat.Store.Postgres.Migrations.M20260908_wallet_seeds else exposed-modules: Simplex.Chat.Archive @@ -329,6 +332,7 @@ library Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link Simplex.Chat.Store.SQLite.Migrations.M20260828_file_expiry + Simplex.Chat.Store.SQLite.Migrations.M20260908_wallet_seeds other-modules: Paths_simplex_chat hs-source-dirs: @@ -617,6 +621,7 @@ test-suite simplex-chat-test RemoteTests ValidNames ViewTests + WalletTests API.Docs.Commands API.Docs.Events API.Docs.Generate diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 54dfb58fc3..0f1e77cbc0 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -68,6 +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 (AccountIndex) import Simplex.Chat.Util (liftIOEither) import Simplex.FileTransfer.Description (FileDescriptionURI) import Simplex.Messaging.Server.Information (ServerPublicInfo) @@ -324,7 +325,7 @@ data ChatController = ChatController contactMergeEnabled :: TVar Bool } -data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSRemote | HSSettings | HSDatabase +data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSRemote | HSSettings | HSDatabase | HSWallet deriving (Show) data ChatCommand @@ -416,6 +417,10 @@ 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 + | APIWalletImport {recoveryPhrase :: Text} + | APIWalletExport | APISendCallInvitation ContactId CallType | SendCallInvitation ContactName CallType | APIRejectCall ContactId @@ -842,6 +847,8 @@ 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, walletAccount :: Maybe (AccountIndex, Text, Text)} + | CRWalletPhrase {user :: User, recoveryPhrase :: 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]} diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index 56ed65fb4b..7ca1d632cd 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -14,6 +14,7 @@ module Simplex.Chat.Help markdownInfo, settingsInfo, databaseHelpInfo, + walletHelpInfo, ) where @@ -88,7 +89,7 @@ chatHelpInfo = green "Create your address: " <> highlight "/address", "", green "Other commands:", - indent <> highlight "/help " <> " - help on: " <> listHighlight ["groups", "contacts", "messages", "files", "address", "incognito", "remote", "settings", "db"], + indent <> highlight "/help " <> " - help on: " <> listHighlight ["groups", "contacts", "messages", "files", "address", "incognito", "remote", "settings", "db", "wallet"], indent <> highlight "/profile " <> " - show / update user profile", indent <> highlight "/delete " <> " - delete contact and all messages with them", indent <> highlight "/chats " <> " - most recent chats", @@ -218,6 +219,26 @@ myAddressHelpInfo = "The commands may be abbreviated: " <> listHighlight ["/ad", "/da", "/sa", "/ac", "/rc"] ] +walletHelpInfo :: [StyledString] +walletHelpInfo = + map + styleMarkdown + [ green "Your wallet key:", + indent <> highlight "/wallet " <> " - the address that would own the next name you buy", + indent <> highlight "/wallet create " <> " - create the key, and this profile's account under it", + indent <> highlight "/wallet import " <> " - use a key you already have", + indent <> highlight "/wallet export " <> " - the recovery phrase, to write down", + "", + "One key per device, and one account per chat profile under it. A name gets", + "its own key at " <> highlight "m/44'/60'/'/0/" <> ". That is ordinary BIP-44,", + "so importing the phrase into another wallet reaches the same addresses.", + "", + "Anyone who knows a recovery phrase controls the names it owns. The risk is", + "theft, not loss.", + "", + "Please note: this is in development. Nothing can be bought or signed yet." + ] + incognitoHelpInfo :: [StyledString] incognitoHelpInfo = map diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 2f2a25d8fd..c8672ab10b 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -58,6 +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 (boundAccount, deviceSeed, getOrCreateAccountRef) +import Simplex.Chat.Wallet (AccountRef (..), accountAddress, deriveNameKey, importRecoveryKey, newSeed, recoveryKeyPhrase, renderNameKeyPath) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -104,6 +106,7 @@ 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 @@ -1488,6 +1491,34 @@ 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) + -- Read-only: a profile is never given keys as a side effect of asking which + -- address it has. + APIWallet -> withUser $ \user -> do + exists <- isJust <$> withFastStore' deviceSeed + acc_ <- withFastStore' $ \db -> boundAccount db user + -- name index 0: with no purchases yet the next name is always the first + a <- forM acc_ $ \(seed, AccountRef {arIndex}) -> do + acc <- either (throwCmdError . ("wallet: " <>)) pure $ deriveNameKey seed arIndex 0 + pure (arIndex, renderNameKeyPath arIndex 0, tshow (accountAddress acc)) + pure $ CRWallet user exists a + -- Creates the device key on first use, and this profile's account under it. + -- A second profile lands on its own account index rather than sharing one. + APIWalletCreate -> withUser $ \user -> do + g <- asks random + void $ withFastStore' $ \db -> getOrCreateAccountRef db user (atomically $ newSeed MS256 g) + processChatCommand cxt nm APIWallet + -- One key per device: importing onto a device that already has one would make + -- the addresses it shows depend on which key was picked. + APIWalletImport phrase -> withUser $ \user -> do + exists <- isJust <$> withFastStore' deviceSeed + when exists $ throwCmdError "this device already has a wallet key" + entropy <- either (throwCmdError . ("wallet: " <>)) pure $ importRecoveryKey (encodeUtf8 phrase) + void $ withFastStore' $ \db -> getOrCreateAccountRef db user (pure entropy) + processChatCommand cxt nm APIWallet + APIWalletExport -> withUser $ \user -> do + seed <- withFastStore' deviceSeed >>= maybe (throwCmdError "no wallet key on this device") pure + phrase <- either (throwCmdError . ("wallet: " <>)) pure $ recoveryKeyPhrase seed + pure $ CRWalletPhrase user (safeDecodeUtf8 phrase) APISendCallInvitation contactId callType -> withUser $ \user -> do -- party initiating call ct <- withFastStore $ \db -> getContact db cxt user contactId @@ -5542,6 +5573,10 @@ 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" $> APIWalletCreate, + "/wallet import " *> (APIWalletImport <$> textP), + "/wallet export" $> APIWalletExport, + "/wallet" $> APIWallet, "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP), "/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType), "/_call reject @" *> (APIRejectCall <$> A.decimal), @@ -5661,6 +5696,7 @@ chatCommandP = ("/help remote" <|> "/hr") $> ChatHelp HSRemote, ("/help settings" <|> "/hs") $> ChatHelp HSSettings, ("/help db" <|> "/hd") $> ChatHelp HSDatabase, + ("/help wallet" <|> "/hw") $> ChatHelp HSWallet, ("/help" <|> "/h") $> ChatHelp HSMain, ("/group" <|> "/g") *> (NewGroup <$> incognitoP <* A.space <* char_ '#' <*> groupProfile), "/_group " *> (APINewGroup <$> A.decimal <*> incognitoOnOffP <* A.space <*> jsonP), diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 91f2b13fb5..09b1129ef8 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -49,6 +49,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejectio import Simplex.Chat.Store.Postgres.Migrations.M20260813_auto_accept_group_invitations import Simplex.Chat.Store.Postgres.Migrations.M20260822_forward_link import Simplex.Chat.Store.Postgres.Migrations.M20260828_file_expiry +import Simplex.Chat.Store.Postgres.Migrations.M20260908_wallet_seeds import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Text, Maybe Text)] @@ -97,7 +98,8 @@ schemaMigrations = ("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection), ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations), ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link), - ("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry) + ("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry), + ("20260908_wallet_seeds", m20260908_wallet_seeds, Just down_m20260908_wallet_seeds) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs new file mode 100644 index 0000000000..c91c96ac3a --- /dev/null +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs @@ -0,0 +1,35 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.Postgres.Migrations.M20260908_wallet_seeds where + +import Data.Text (Text) +import Text.RawString.QQ (r) + +m20260908_wallet_seeds :: Text +m20260908_wallet_seeds = + [r| +CREATE TABLE wallet_seeds ( + wallet_seed_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, + seed BYTEA NOT NULL, + -- High-water mark for account allocation; see the SQLite migration for why + -- this cannot be derived from MAX(users.wallet_account_index). + next_account_index BIGINT NOT NULL DEFAULT 0 +); + +ALTER TABLE users ADD COLUMN wallet_seed_id BIGINT REFERENCES wallet_seeds ON DELETE RESTRICT; +ALTER TABLE users ADD COLUMN wallet_account_index BIGINT; + +CREATE INDEX idx_users_wallet_seed_id ON users(wallet_seed_id); +|] + +down_m20260908_wallet_seeds :: Text +down_m20260908_wallet_seeds = + [r| +DROP INDEX idx_users_wallet_seed_id; + +ALTER TABLE users DROP COLUMN wallet_account_index; +ALTER TABLE users DROP COLUMN wallet_seed_id; + +DROP TABLE wallet_seeds; +|] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index a6d9fbe4cb..8c6526e5b2 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1517,7 +1517,9 @@ CREATE TABLE test_chat_schema.users ( auto_accept_member_contacts smallint DEFAULT 0 NOT NULL, is_user_chat_relay smallint DEFAULT 0 NOT NULL, client_service smallint DEFAULT 0 NOT NULL, - auto_accept_group_invitations smallint DEFAULT 0 NOT NULL + auto_accept_group_invitations smallint DEFAULT 0 NOT NULL, + wallet_seed_id bigint, + wallet_account_index bigint ); @@ -1533,6 +1535,25 @@ ALTER TABLE test_chat_schema.users ALTER COLUMN user_id ADD GENERATED ALWAYS AS +CREATE TABLE test_chat_schema.wallet_seeds ( + wallet_seed_id bigint NOT NULL, + seed bytea NOT NULL, + next_account_index bigint DEFAULT 0 NOT NULL +); + + + +ALTER TABLE test_chat_schema.wallet_seeds ALTER COLUMN wallet_seed_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.wallet_seeds_wallet_seed_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.xftp_file_descriptions ( file_descr_id bigint NOT NULL, user_id bigint NOT NULL, @@ -1891,6 +1912,11 @@ ALTER TABLE ONLY test_chat_schema.users +ALTER TABLE ONLY test_chat_schema.wallet_seeds + ADD CONSTRAINT wallet_seeds_pkey PRIMARY KEY (wallet_seed_id); + + + ALTER TABLE ONLY test_chat_schema.xftp_file_descriptions ADD CONSTRAINT xftp_file_descriptions_pkey PRIMARY KEY (file_descr_id); @@ -2640,6 +2666,10 @@ CREATE UNIQUE INDEX idx_user_contact_links_group_id ON test_chat_schema.user_con +CREATE INDEX idx_users_wallet_seed_id ON test_chat_schema.users USING btree (wallet_seed_id); + + + CREATE INDEX idx_xftp_file_descriptions_user_id ON test_chat_schema.xftp_file_descriptions USING btree (user_id); @@ -3315,6 +3345,11 @@ ALTER TABLE ONLY test_chat_schema.user_contact_links +ALTER TABLE ONLY test_chat_schema.users + ADD CONSTRAINT users_wallet_seed_id_fkey FOREIGN KEY (wallet_seed_id) REFERENCES test_chat_schema.wallet_seeds(wallet_seed_id) ON DELETE RESTRICT; + + + ALTER TABLE ONLY test_chat_schema.xftp_file_descriptions ADD CONSTRAINT xftp_file_descriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index e3f5f9385b..b7ce50316e 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -172,6 +172,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection import Simplex.Chat.Store.SQLite.Migrations.M20260813_auto_accept_group_invitations import Simplex.Chat.Store.SQLite.Migrations.M20260822_forward_link import Simplex.Chat.Store.SQLite.Migrations.M20260828_file_expiry +import Simplex.Chat.Store.SQLite.Migrations.M20260908_wallet_seeds import Simplex.Messaging.Agent.Store.Shared (Migration (..)) schemaMigrations :: [(String, Query, Maybe Query)] @@ -343,7 +344,8 @@ schemaMigrations = ("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection), ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations), ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link), - ("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry) + ("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry), + ("20260908_wallet_seeds", m20260908_wallet_seeds, Just down_m20260908_wallet_seeds) ] -- | The list of migrations in ascending order by date diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs new file mode 100644 index 0000000000..5255241bfd --- /dev/null +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs @@ -0,0 +1,42 @@ +{-# LANGUAGE QuasiQuotes #-} + +module Simplex.Chat.Store.SQLite.Migrations.M20260908_wallet_seeds where + +import Database.SQLite.Simple (Query) +import Database.SQLite.Simple.QQ (sql) + +-- | Wallet seeds: BIP-39 entropy, one or more per database. +-- +-- The schema allows several seeds, with each chat profile bound to exactly one +-- of them plus its own BIP-44 account index. Only the single-seed case is +-- reachable today. +m20260908_wallet_seeds :: Query +m20260908_wallet_seeds = + [sql| +CREATE TABLE wallet_seeds ( + wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, + seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes + -- High-water mark for account allocation. Deliberately not derived from + -- MAX(users.wallet_account_index): after recovery from the phrase alone that + -- table is empty while accounts 0..N already hold names on chain, so a new + -- profile would silently reuse a recovered account's keys. + next_account_index INTEGER NOT NULL DEFAULT 0 +) STRICT; + +ALTER TABLE users ADD COLUMN wallet_seed_id INTEGER REFERENCES wallet_seeds ON DELETE RESTRICT; +ALTER TABLE users ADD COLUMN wallet_account_index INTEGER; + +-- required: the .lint check enforces an index on every foreign key +CREATE INDEX idx_users_wallet_seed_id ON users(wallet_seed_id); +|] + +down_m20260908_wallet_seeds :: Query +down_m20260908_wallet_seeds = + [sql| +DROP INDEX idx_users_wallet_seed_id; + +ALTER TABLE users DROP COLUMN wallet_account_index; +ALTER TABLE users DROP COLUMN wallet_seed_id; + +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 90706e70dd..7b616ebd3b 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -54,7 +54,9 @@ CREATE TABLE users( auto_accept_member_contacts INTEGER NOT NULL DEFAULT 0, is_user_chat_relay INTEGER NOT NULL DEFAULT 0, client_service INTEGER NOT NULL DEFAULT 0, - auto_accept_group_invitations INTEGER NOT NULL DEFAULT 0, -- 1 for active user + auto_accept_group_invitations INTEGER NOT NULL DEFAULT 0, + wallet_seed_id INTEGER REFERENCES wallet_seeds ON DELETE RESTRICT, + wallet_account_index INTEGER, -- 1 for active user FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE RESTRICT @@ -852,6 +854,15 @@ CREATE TABLE rcv_roster_transfers( created_at TEXT NOT NULL DEFAULT(datetime('now')), updated_at TEXT NOT NULL DEFAULT(datetime('now')) ) STRICT; +CREATE TABLE wallet_seeds( + wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, + seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes + -- High-water mark for account allocation. Deliberately not derived from + -- MAX(users.wallet_account_index): after recovery from the phrase alone that + -- table is empty while accounts 0..N already hold names on chain, so a new + -- profile would silently reuse a recovered account's keys. +next_account_index INTEGER NOT NULL DEFAULT 0 +) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( display_name, full_name @@ -1386,6 +1397,7 @@ CREATE INDEX idx_files_roster_transfer_id ON files(roster_transfer_id); CREATE INDEX idx_chat_items_item_signed_by_group_member_id ON chat_items( item_signed_by_group_member_id ); +CREATE INDEX idx_users_wallet_seed_id ON users(wallet_seed_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 new file mode 100644 index 0000000000..32eb213d10 --- /dev/null +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -0,0 +1,109 @@ +{-# LANGUAGE CPP #-} +{-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} +{-# LANGUAGE OverloadedStrings #-} + +-- | Persistence for wallet seeds and per-profile accounts. +-- +-- The schema holds several seeds and binds each chat profile to one of them +-- plus its own account index. One seed per device is reachable today, so +-- 'deviceSeed' is the seed, and 'getOrCreateAccountRef' creates it on first use. +module Simplex.Chat.Store.Wallets + ( deviceSeed, + boundAccount, + getOrCreateAccountRef, + ) +where + +import Data.ByteString (ByteString) +import Data.Int (Int64) +import Simplex.Chat.Store.Shared (insertedRowId) +import Simplex.Chat.Types (User (..)) +import Simplex.Chat.Wallet (AccountIndex, AccountRef (..), SeedId (..), WalletSeed (..)) +import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) +import qualified Simplex.Messaging.Agent.Store.DB as DB + +#if defined(dbPostgres) +import Database.PostgreSQL.Simple (Only (..)) +#else +import Database.SQLite.Simple (Only (..)) +#endif + +toSeed :: (Int64, ByteString) -> WalletSeed +toSeed (sId, seed) = WalletSeed {wsId = SeedId sId, wsEntropy = seed} + +-- | The seed on this device, or Nothing if the wallet has never been used. +deviceSeed :: DB.Connection -> IO (Maybe WalletSeed) +deviceSeed db = + maybeFirstRow toSeed $ + DB.query_ db "SELECT wallet_seed_id, seed FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1" + +getWalletSeed :: DB.Connection -> SeedId -> IO (Maybe WalletSeed) +getWalletSeed db (SeedId sId) = + maybeFirstRow toSeed $ + DB.query db "SELECT wallet_seed_id, seed FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) + +-- | Insert a seed. Callers generate the entropy; this module never does, so the +-- DRG stays with the agent. +createWalletSeed :: DB.Connection -> ByteString -> IO WalletSeed +createWalletSeed db seed = do + DB.execute db "INSERT INTO wallet_seeds (seed) VALUES (?)" (Only seed) + sId <- insertedRowId db + pure WalletSeed {wsId = SeedId sId, wsEntropy = seed} + +getAccountRef :: DB.Connection -> User -> IO (Maybe AccountRef) +getAccountRef db User {userId} = do + r <- + maybeFirstRow id $ + DB.query db "SELECT wallet_seed_id, wallet_account_index FROM users WHERE user_id = ?" (Only userId) + pure $ case r of + Just (Just sId, Just ix) -> Just AccountRef {arSeedId = SeedId sId, arIndex = fromIntegral (ix :: Int64)} + _ -> Nothing + +bindAccount :: DB.Connection -> User -> AccountRef -> IO () +bindAccount db User {userId} AccountRef {arSeedId = SeedId sId, arIndex} = + DB.execute + db + "UPDATE users SET wallet_seed_id = ?, wallet_account_index = ? WHERE user_id = ?" + (sId, fromIntegral arIndex :: Int64, userId) + +-- | The seed and account this profile is bound to, or Nothing if it has never +-- used the wallet. Creates nothing: a profile is never given keys as a side +-- effect of reading. +boundAccount :: DB.Connection -> User -> IO (Maybe (WalletSeed, AccountRef)) +boundAccount db user = + getAccountRef db user >>= \case + Nothing -> pure Nothing + Just r -> fmap (\s -> (s, r)) <$> getWalletSeed db (arSeedId r) + +-- | Bind this profile to the device's seed, creating that seed from @mkSeed@ if +-- there is none yet. Every profile gets its own account index within it. +getOrCreateAccountRef :: DB.Connection -> User -> IO ByteString -> IO (WalletSeed, AccountRef) +getOrCreateAccountRef db user mkSeed = + boundAccount db user >>= \case + Just bound -> pure bound + Nothing -> do + s <- deviceSeed db >>= maybe (mkSeed >>= createWalletSeed db) pure + ix <- takeAccountIndex db (wsId s) + let r = AccountRef {arSeedId = wsId s, arIndex = ix} + bindAccount db user r + pure (s, r) + +-- | Take the next account index and advance the seed's high-water mark. +-- +-- The mark is stored rather than computed as @MAX(users.wallet_account_index)@, +-- because after recovery from the phrase alone the @users@ table is empty while +-- accounts @0..N@ already hold names on chain. Computing it would hand the first +-- newly created profile index 0 and, with it, a recovered account's keys. +takeAccountIndex :: DB.Connection -> SeedId -> IO AccountIndex +takeAccountIndex db sId@(SeedId sId') = do + ix <- getNextAccountIndex db sId + DB.execute db "UPDATE wallet_seeds SET next_account_index = ? WHERE wallet_seed_id = ?" (fromIntegral ix + 1 :: Int64, sId') + pure ix + +getNextAccountIndex :: DB.Connection -> SeedId -> IO AccountIndex +getNextAccountIndex db (SeedId sId) = + maybe 0 (fromIntegral :: Int64 -> AccountIndex) + <$> ( maybeFirstRow fromOnly $ + DB.query db "SELECT next_account_index FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) + ) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 650157145b..d50695e532 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -181,6 +181,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te HSRemote -> remoteHelpInfo HSSettings -> settingsInfo HSDatabase -> databaseHelpInfo + HSWallet -> walletHelpInfo CRWelcome user -> chatWelcome user CRContactsList u cs -> ttyUser u $ viewContactsList cs CRUserContactLink u UserContactLink {connLinkContact, addressSettings} -> ttyUser u $ connReqContact_ showFullLinks "Your chat address:" connLinkContact <> viewAddressSettings addressSettings @@ -188,6 +189,21 @@ 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 acc_ -> + ttyUser u $ case acc_ of + Just (acct, path, addr) -> + [ plain $ "wallet account " <> tshow acct, + plain $ "next name will be owned by " <> addr, + plain $ " at " <> path + ] + Nothing + | exists -> ["wallet key on this device, but this profile has no account - add one with " <> highlight' "/wallet create"] + | otherwise -> ["no wallet key on this device - create one with " <> highlight' "/wallet create"] + CRWalletPhrase u phrase -> + ttyUser u + [ "write this down - anyone who knows these words controls the names this key owns:", + plain $ " " <> phrase + ] 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 diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs new file mode 100644 index 0000000000..c4a031a92b --- /dev/null +++ b/src/Simplex/Chat/Wallet.hs @@ -0,0 +1,131 @@ +{-# LANGUAGE OverloadedStrings #-} + +-- | The wallet: BIP-39 seeds, and the keys derived from them. +-- +-- * __seed__ — BIP-39 entropy. Generic, /not/ name-specific. +-- * __account__ — a profile's slot in a seed, BIP-44 account index @i@. +-- * __name key__ — @m\/44'\/60'\/i'\/0\/k@: one key per name, at BIP-44 +-- address index @k@ under the profile that buys it. This is what the +-- registry records as the name's owner. +-- * __wallet__ — this module: creation and derivation. +-- +-- One key per name, not one per profile. A per-profile key would mean exporting +-- it hands over every name that profile owns, and would put every name's signed +-- record edits behind one shared nonce counter on the resolver. Both are avoided +-- by giving each name its own address index. @k = 0@ is the profile's first +-- name. +-- +-- The schema allows several seeds; a profile binds to exactly one plus its own +-- account index. Only one seed per device is reachable today. +-- +-- This module is pure. Persistence lives in "Simplex.Chat.Store.Wallets". +-- +-- There is no signing here yet, so nothing can be bought or edited: this is the +-- key material and the derivation only. +module Simplex.Chat.Wallet + ( SeedId (..), + WalletSeed (..), + AccountIndex, + NameIndex, + AccountRef (..), + WalletAccount (..), + newSeed, + importRecoveryKey, + recoveryKeyPhrase, + deriveNameKey, + nameKeyPath, + renderNameKeyPath, + accountAddress, + ) +where + +import Control.Concurrent.STM +import Crypto.Random (ChaChaDRG) +import Data.ByteString (ByteString) +import Data.Int (Int64) +import Data.Text (Text) +import Data.Text.Encoding (decodeLatin1) +import Data.Word (Word32) +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 (Address, addressFromPrivateKey) + +newtype SeedId = SeedId Int64 + deriving (Eq, Ord, Show) + +-- | BIP-44 account index within a seed. One per chat profile. +type AccountIndex = Word32 + +-- | BIP-44 address index within a profile account. One per name. +type NameIndex = Word32 + +-- | A seed, held as BIP-39 entropy. Stored in the chat database so it rides the +-- existing archive export and Migrate-to-another-device flows. +-- +-- 'Show' is redacting: this is the root secret behind every name it owns. +data WalletSeed = WalletSeed + { wsId :: SeedId, + wsEntropy :: ByteString + } + deriving (Eq) + +instance Show WalletSeed where + show s = "WalletSeed " <> show (wsId s) <> " " + +-- | What a chat profile stores: which seed, and which account index within it. +data AccountRef = AccountRef + { arSeedId :: SeedId, + arIndex :: AccountIndex + } + deriving (Eq, Show) + +-- | A derived account: the reference plus the key it resolves to. +data WalletAccount = WalletAccount + { waRef :: AccountRef, + waKey :: S.PrivateKey + } + deriving (Eq) + +instance Show WalletAccount where + show a = "WalletAccount " <> show (waRef a) <> " " + +-- | Fresh seed entropy. The caller stores it; this module never persists. +-- A 25th-word passphrase is deliberately not used — 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 + +-- | Import from a recovery phrase, validating the wordlist and the BIP-39 +-- checksum. Returns the entropy; the caller persists it. +importRecoveryKey :: ByteString -> Either String ByteString +importRecoveryKey phrase = B39.mnemonicToEntropy <$> B39.parseMnemonic phrase + +-- | The phrase to show under "recovery key". Anyone who knows these words +-- controls every name this seed owns, so the risk to state is theft, not loss. +recoveryKeyPhrase :: WalletSeed -> Either String ByteString +recoveryKeyPhrase s = B39.mnemonicPhrase <$> B39.entropyToMnemonic (wsEntropy s) + +-- | @m\/44'\/60'\/i'\/0\/k@ — the standard BIP-44 layout, with the profile at +-- the account level and the name at the address level. Nothing here is a custom +-- path, so profile @i@'s names are the account list an ordinary Ethereum wallet +-- would show for that account. +nameKeyPath :: AccountIndex -> NameIndex -> [Word32] +nameKeyPath acc nm = [B32.hardened 44, B32.hardened 60, B32.hardened acc, 0, nm] + +-- | The path a name's key was derived at, for display. Users need it only to +-- import a single name into a third-party wallet. +renderNameKeyPath :: AccountIndex -> NameIndex -> Text +renderNameKeyPath acc nm = decodeLatin1 . B32.renderPath $ nameKeyPath acc nm + +-- | Derive the key that owns one name. +deriveNameKey :: WalletSeed -> AccountIndex -> NameIndex -> Either String WalletAccount +deriveNameKey s acc nm = do + m <- B39.entropyToMnemonic (wsEntropy s) + master <- B32.masterKey (B39.mnemonicToSeed m "") + xk <- B32.derivePath master (nameKeyPath acc nm) + pure WalletAccount {waRef = AccountRef {arSeedId = wsId s, arIndex = acc}, waKey = B32.xkKey xk} + +-- | The Ethereum address that owns the name this key was derived for. +accountAddress :: WalletAccount -> Address +accountAddress = addressFromPrivateKey . waKey diff --git a/tests/Test.hs b/tests/Test.hs index 84a019c21c..eb82eaaa9a 100644 --- a/tests/Test.hs +++ b/tests/Test.hs @@ -25,6 +25,7 @@ import Test.Hspec hiding (it) import UnliftIO.Temporary (withTempDirectory) import ValidNames import ViewTests +import WalletTests #if defined(dbPostgres) import Control.Exception (bracket_) import PostgresSchemaDump @@ -67,6 +68,7 @@ main = do describe "JSON Tests" jsonTests describe "Member relations" memberRelationsTests describe "SimpleX chat view" viewTests + describe "Wallet derivation" walletDerivationTests describe "SimpleX chat protocol" protocolTests describe "Valid names" validNameTests describe "Message batching" batchingTests @@ -86,6 +88,7 @@ main = do describe "Mobile API Tests" mobileTests #endif describe "SimpleX chat client" chatTests + describe "Wallet" walletTests xdescribe'' "SimpleX Broadcast bot" broadcastBotTests xdescribe'' "SimpleX Directory service bot" directoryServiceTests describe "Remote session" remoteTests diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs new file mode 100644 index 0000000000..9c39797dc7 --- /dev/null +++ b/tests/WalletTests.hs @@ -0,0 +1,118 @@ +{-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE PostfixOperators #-} + +module WalletTests where + +import ChatClient +import ChatTests.DBUtils +import ChatTests.Utils +import Control.Monad (replicateM_) +import Data.ByteString.Char8 (ByteString) +import qualified Data.ByteString.Char8 as B +import Data.Either (isLeft) +import Simplex.Chat.Help (walletHelpInfo) +import Simplex.Chat.Wallet (SeedId (..), WalletSeed (..), accountAddress, deriveNameKey, importRecoveryKey, recoveryKeyPhrase, renderNameKeyPath) +import Test.Hspec hiding (it) +import qualified Test.Hspec as Hspec + +-- | The BIP-39 test vector every wallet ships with, so the addresses below can +-- be checked against MetaMask and Ledger Live. +testPhrase :: ByteString +testPhrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" + +testSeed :: WalletSeed +testSeed = WalletSeed {wsId = SeedId 1, wsEntropy = either error id $ importRecoveryKey testPhrase} + +-- | Derivation is the part other wallets have to agree with, so it is pinned +-- here rather than left to the end-to-end tests. +walletDerivationTests :: Spec +walletDerivationTests = do + -- Name keys are plain BIP-44: profile 0's names are exactly MetaMask's + -- account list (m/44'/60'/0'/0/k), and each profile's first name is the + -- matching Ledger Live account (m/44'/60'/i'/0/0). That is what lets an owner + -- move a single name into another wallet. + Hspec.it "name keys line up with other wallets' derivation" $ do + let addrOf i k = either error (show . accountAddress) (deriveNameKey testSeed i k) + -- MetaMask accounts 1 and 2 for this phrase + addrOf 0 0 `shouldBe` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + addrOf 0 1 `shouldBe` "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" + -- Ledger Live account 2 for this phrase + addrOf 1 0 `shouldBe` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265" + Hspec.it "renders the path a name key sits at" $ do + renderNameKeyPath 0 0 `shouldBe` "m/44'/60'/0'/0/0" + renderNameKeyPath 2 7 `shouldBe` "m/44'/60'/2'/0/7" + Hspec.it "round-trips the phrase it was imported from" $ + recoveryKeyPhrase testSeed `shouldBe` Right testPhrase + -- Without the checksum a mistyped word imports a key that owns nothing. + Hspec.it "refuses a phrase with a bad checksum" $ + importRecoveryKey (B.unwords $ replicate 12 "abandon") `shouldSatisfy` isLeft + +walletTests :: SpecWith TestParams +walletTests = do + it "creates no key until asked, then shows the next name's address" testWalletCreate + it "the key and the address come back after a restart" testWalletPersists + it "a second profile gets its own account" testWalletSecondProfile + it "imports a phrase, exports it, and refuses a second import" testWalletImport + +-- | The address a name would be bought at, and the path to reach it from the +-- phrase. Returned so tests can compare addresses without pinning a random one. +nextNameAddress :: HasCallStack => TestCC -> Int -> IO String +nextNameAddress cc acct = do + cc <## ("wallet account " <> show acct) + addr <- getTermLine cc + cc <## (" at m/44'/60'/" <> show acct <> "'/0/0") + pure addr + +testWalletCreate :: HasCallStack => TestParams -> IO () +testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/wallet" + alice <## "no wallet key on this device - create one with /wallet create" + -- asked again: still nothing, and nothing was created by asking + alice ##> "/wallet" + alice <## "no wallet key on this device - create one with /wallet create" + alice ##> "/wallet export" + alice <## "bad chat command: no wallet key on this device" + alice ##> "/wallet create" + _ <- nextNameAddress alice 0 + alice ##> "/help wallet" + alice <## "Your wallet key:" + replicateM_ (length walletHelpInfo - 1) (getTermLine alice) + +testWalletPersists :: HasCallStack => TestParams -> IO () +testWalletPersists ps = do + addr <- withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/wallet create" + nextNameAddress alice 0 + -- same database, new session: the seed has to come back from the DB, or the + -- name bought at that address is unreachable + withTestChat ps "alice" $ \alice -> do + alice ##> "/wallet" + addr' <- nextNameAddress alice 0 + addr' `shouldBe` addr + +testWalletSecondProfile :: HasCallStack => TestParams -> IO () +testWalletSecondProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/wallet create" + addr <- nextNameAddress alice 0 + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/wallet" + alice <## "wallet key on this device, but this profile has no account - add one with /wallet create" + -- the same key, a different account, so the two profiles do not share names + alice ##> "/wallet create" + addr' <- nextNameAddress alice 1 + addr' `shouldNotBe` addr + +testWalletImport :: HasCallStack => TestParams -> IO () +testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> ("/wallet import " <> B.unpack testPhrase) + alice <## "wallet account 0" + alice <## "next name will be owned by 0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + alice <## " at m/44'/60'/0'/0/0" + alice ##> "/wallet export" + alice <## "write this down - anyone who knows these words controls the names this key owns:" + alice <## (" " <> B.unpack testPhrase) + -- one key per device: a second would make the address shown depend on which + -- key was picked + alice ##> ("/wallet import " <> B.unpack testPhrase) + alice <## "bad chat command: this device already has a wallet key" From 257cdc02c8df223dd86b7169812f479e6f47aa5c Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 8 Sep 2026 15:47:40 +0000 Subject: [PATCH 02/67] core: pin simplexmq with eth crypto merged into master ab/eth-crypto forked before Crypto.Entitlement, which Chat.Badges imports, so the branch had to be merged with simplexmq master before it could be pinned here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index 726e86fdbf..0a6efd7a2f 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: b23d3a3265b4511afbaab8d7caa1528a662df6e0 + tag: a23da20fb53eb6e43012aa5ee305ed41f7f6f6ab source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 79eedf458c..7b99c12b39 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."b23d3a3265b4511afbaab8d7caa1528a662df6e0" = "1kybaa4bb2jbh3jiaacxgvaixka6z9nvgxgvdm09jsk2w8r9h90x"; + "https://github.com/simplex-chat/simplexmq.git"."a23da20fb53eb6e43012aa5ee305ed41f7f6f6ab" = "1s0m80i0z7b5x7xx2rqkzqv28zaxmmfm4x1vkgka1vb9qxmpb1p1"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; From ee11b368b6d958333d2d440081538f2d665943a4 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 9 Sep 2026 09:05:47 +0000 Subject: [PATCH 03/67] core: wallet review fixes, and show the derived addresses Concurrency: the account index is incremented in SQL and read back in the same transaction, so two profiles cannot be handed the same key. Importing a phrase is one transaction and single_seed is UNIQUE, so a phrase cannot be discarded in favour of a key created meanwhile, and a device cannot end up with two keys. Wallet commands are no longer forwarded to a remote host: the recovery phrase must not leave the device, and the raw command is logged there. /wallet delete removes the key, confirmed by the last word of the phrase, so creating a key before importing your own is no longer a dead end. /wallet now shows every profile on the key with the first two name addresses each, to check derivation against other wallets. Hidden profiles are left out, as they are by /users. A bad phrase no longer says which word was wrong. /wallet export uses the profile's own key. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- CHANGELOG.md | 15 +-- bots/src/API/Docs/Commands.hs | 1 + src/Simplex/Chat/Controller.hs | 8 +- src/Simplex/Chat/Help.hs | 26 ++-- src/Simplex/Chat/Library/Commands.hs | 57 +++++---- .../Migrations/M20260908_wallet_seeds.hs | 7 +- .../Store/Postgres/Migrations/chat_schema.sql | 8 +- .../Migrations/M20260908_wallet_seeds.hs | 17 +-- .../Store/SQLite/Migrations/chat_schema.sql | 11 +- src/Simplex/Chat/Store/Wallets.hs | 113 +++++++++++------- src/Simplex/Chat/View.hs | 21 ++-- src/Simplex/Chat/Wallet.hs | 12 +- tests/WalletTests.hs | 88 +++++++++----- 13 files changed, 232 insertions(+), 152 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index fa98875d6f..8a35756854 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,15 +2,12 @@ ## Unreleased -Wallet (in development, CLI only). A key on the device, so that a name bought -later has an owner the app can still derive: -- `/wallet create` creates one BIP-39 key per device and one BIP-44 account per - chat profile under it, `/wallet` shows the address that would own the next - name that profile buys, `/wallet import` and `/wallet export` move the key - with its recovery phrase. -- A name key sits at `m/44'/60'/'/0/`, which is ordinary BIP-44, - so the phrase reaches the same addresses in other wallets. -- No signing, so nothing can be bought or edited yet. +Wallet (in development, CLI only): creates the key that will own your SimpleX +names, so a name you buy later has an owner this app can still derive. One key +per device, one account per chat profile, each name at `m/44'/60'/'/0/` +(standard BIP-44, so your recovery phrase works in other wallets). `/wallet` +shows the addresses, `/wallet import` and `/wallet export` move the key, +`/wallet delete` removes it. You cannot buy a name yet. ## v6.5 diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index c875c3f5e6..c08e63fe0c 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -452,6 +452,7 @@ undocumentedCommands = "APIVerifyToken", "APIWallet", "APIWalletCreate", + "APIWalletDelete", "APIWalletExport", "APIWalletImport", "CheckChatRunning", diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 0f1e77cbc0..37e900f8a9 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -421,6 +421,7 @@ data ChatCommand | APIWalletCreate | APIWalletImport {recoveryPhrase :: Text} | APIWalletExport + | APIWalletDelete {confirmWord :: Text} | APISendCallInvitation ContactId CallType | SendCallInvitation ContactName CallType | APIRejectCall ContactId @@ -746,6 +747,11 @@ allowRemoteCommand = \case DeleteRemoteCtrl _ -> False ExecChatStoreSQL _ -> False ExecAgentStoreSQL _ -> False + APIWallet -> False + APIWalletCreate -> False + APIWalletImport _ -> False + APIWalletExport -> False + APIWalletDelete _ -> False _ -> True data RelayConnectionResult = RelayConnectionResult @@ -847,7 +853,7 @@ 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, walletAccount :: Maybe (AccountIndex, Text, Text)} + | CRWallet {user :: User, walletKeyExists :: Bool, walletAccounts :: [(Text, AccountIndex, Bool, [(Text, Text)])]} | CRWalletPhrase {user :: User, recoveryPhrase :: Text} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} | CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool} diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index 7ca1d632cd..81be9ce8ac 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -223,20 +223,24 @@ walletHelpInfo :: [StyledString] walletHelpInfo = map styleMarkdown - [ green "Your wallet key:", - indent <> highlight "/wallet " <> " - the address that would own the next name you buy", - indent <> highlight "/wallet create " <> " - create the key, and this profile's account under it", - indent <> highlight "/wallet import " <> " - use a key you already have", - indent <> highlight "/wallet export " <> " - the recovery phrase, to write down", + [ green "Wallet commands:", + indent <> highlight "/wallet " <> " - your key, and the addresses it derives", + indent <> highlight "/wallet create " <> " - create your key, or add this profile to it", + indent <> highlight "/wallet import " <> " - use a key you already have", + indent <> highlight "/wallet export " <> " - show your recovery phrase", + indent <> highlight "/wallet delete " <> " - delete the key, confirmed by the last word of the phrase", "", - "One key per device, and one account per chat profile under it. A name gets", - "its own key at " <> highlight "m/44'/60'/'/0/" <> ". That is ordinary BIP-44,", - "so importing the phrase into another wallet reaches the same addresses.", + "Please note: this is in development. You cannot buy a name yet.", "", - "Anyone who knows a recovery phrase controls the names it owns. The risk is", - "theft, not loss.", + "One key per device, one account per chat profile. Each name gets its own", + "key at " <> highlight "m/44'/60'/'/0/" <> ". This is standard BIP-44, so your", + "phrase works in other wallets.", "", - "Please note: this is in development. Nothing can be bought or signed yet." + "The key is stored in the chat database. It is only encrypted if you set a", + "database passphrase with " <> highlight "/db encrypt" <> ", and it is included in " <> highlight "/db export" <> ".", + "", + "Anyone who has your recovery phrase controls your names. Keep it secret,", + "and keep a copy." ] incognitoHelpInfo :: [StyledString] diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index c8672ab10b..77018c177a 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 (boundAccount, deviceSeed, getOrCreateAccountRef) -import Simplex.Chat.Wallet (AccountRef (..), accountAddress, deriveNameKey, importRecoveryKey, newSeed, recoveryKeyPhrase, renderNameKeyPath) +import Simplex.Chat.Store.Wallets (deleteSeed, getBoundAccount, getDeviceSeed, getOrCreateAccountRef, getSeedAccounts, importSeed) +import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), accountAddress, deriveNameKey, importRecoveryKey, newSeed, recoveryKeyPhrase, renderNameKeyPath) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -1491,34 +1491,41 @@ 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) - -- Read-only: a profile is never given keys as a side effect of asking which - -- address it has. APIWallet -> withUser $ \user -> do - exists <- isJust <$> withFastStore' deviceSeed - acc_ <- withFastStore' $ \db -> boundAccount db user - -- name index 0: with no purchases yet the next name is always the first - a <- forM acc_ $ \(seed, AccountRef {arIndex}) -> do - acc <- either (throwCmdError . ("wallet: " <>)) pure $ deriveNameKey seed arIndex 0 - pure (arIndex, renderNameKeyPath arIndex 0, tshow (accountAddress acc)) - pure $ CRWallet user exists a - -- Creates the device key on first use, and this profile's account under it. - -- A second profile lands on its own account index rather than sharing one. + seed_ <- withFastStore' getDeviceSeed + accs <- case seed_ of + Nothing -> pure [] + Just seed -> do + -- a hidden profile is left out, as it is by /users + as <- filter (\(_, _, active, hidden) -> active || not hidden) <$> withFastStore' (\db -> getSeedAccounts db (wsId seed)) + forM as $ \(n, acct, active, _) -> do + keys <- forM [0 .. walletNamesShown - 1] $ \k -> do + acc <- either (throwCmdError . ("wallet: " <>)) pure $ deriveNameKey seed acct k + pure (renderNameKeyPath acct k, tshow (accountAddress acc)) + pure (n, acct, active, keys) + pure $ CRWallet user (isJust seed_) accs APIWalletCreate -> withUser $ \user -> do g <- asks random - void $ withFastStore' $ \db -> getOrCreateAccountRef db user (atomically $ newSeed MS256 g) + entropy <- atomically $ newSeed MS256 g + void $ withFastStore' $ \db -> getOrCreateAccountRef db user entropy processChatCommand cxt nm APIWallet - -- One key per device: importing onto a device that already has one would make - -- the addresses it shows depend on which key was picked. APIWalletImport phrase -> withUser $ \user -> do - exists <- isJust <$> withFastStore' deviceSeed - when exists $ throwCmdError "this device already has a wallet key" - entropy <- either (throwCmdError . ("wallet: " <>)) pure $ importRecoveryKey (encodeUtf8 phrase) - void $ withFastStore' $ \db -> getOrCreateAccountRef db user (pure entropy) + entropy <- either (const $ throwCmdError "bad recovery phrase") pure $ importRecoveryKey (encodeUtf8 phrase) + r <- withFastStore' $ \db -> importSeed db user entropy + when (isNothing r) $ throwCmdError "this device already has a wallet key" processChatCommand cxt nm APIWallet APIWalletExport -> withUser $ \user -> do - seed <- withFastStore' deviceSeed >>= maybe (throwCmdError "no wallet key on this device") pure + (seed, _) <- withFastStore' (\db -> getBoundAccount db user) >>= maybe (throwCmdError noKeyError) pure phrase <- either (throwCmdError . ("wallet: " <>)) pure $ recoveryKeyPhrase seed pure $ CRWalletPhrase user (safeDecodeUtf8 phrase) + APIWalletDelete confirmWord -> withUser $ \user -> do + seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure + phrase <- either (throwCmdError . ("wallet: " <>)) pure $ recoveryKeyPhrase seed + case reverse . T.words $ safeDecodeUtf8 phrase of + w : _ | w == confirmWord -> do + withFastStore' $ \db -> deleteSeed db (wsId seed) + processChatCommand cxt nm APIWallet + _ -> throwCmdError "to confirm, pass the last word of the recovery phrase" APISendCallInvitation contactId callType -> withUser $ \user -> do -- party initiating call ct <- withFastStore $ \db -> getContact db cxt user contactId @@ -5455,6 +5462,13 @@ withExpirationDate globalTTL chatItemTTL action = do let ttl = fromMaybe globalTTL chatItemTTL when (ttl > 0) $ action $ addUTCTime (-1 * fromIntegral ttl) currentTs +-- | Name keys shown per profile by /wallet, to check derivation against other wallets. +walletNamesShown :: NameIndex +walletNamesShown = 2 + +noKeyError :: String +noKeyError = "no wallet key for this profile - create one with /wallet create" + chatCommandP :: Parser ChatCommand chatCommandP = choice @@ -5576,6 +5590,7 @@ chatCommandP = "/wallet create" $> APIWalletCreate, "/wallet import " *> (APIWalletImport <$> textP), "/wallet export" $> APIWalletExport, + "/wallet delete " *> (APIWalletDelete <$> textP), "/wallet" $> APIWallet, "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP), "/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType), 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 c91c96ac3a..04b8b3a46f 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs @@ -12,9 +12,10 @@ m20260908_wallet_seeds = CREATE TABLE wallet_seeds ( wallet_seed_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, seed BYTEA NOT NULL, - -- High-water mark for account allocation; see the SQLite migration for why - -- this cannot be derived from MAX(users.wallet_account_index). - next_account_index BIGINT NOT NULL DEFAULT 0 + -- Known issue: see the SQLite migration. + next_account_index BIGINT NOT NULL DEFAULT 0, + -- one key per device for now; drop when several are supported + single_seed SMALLINT NOT NULL DEFAULT 1 UNIQUE ); ALTER TABLE users ADD COLUMN wallet_seed_id BIGINT REFERENCES wallet_seeds ON DELETE RESTRICT; diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 8c6526e5b2..b629dd814f 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1538,7 +1538,8 @@ ALTER TABLE test_chat_schema.users ALTER COLUMN user_id ADD GENERATED ALWAYS AS CREATE TABLE test_chat_schema.wallet_seeds ( wallet_seed_id bigint NOT NULL, seed bytea NOT NULL, - next_account_index bigint DEFAULT 0 NOT NULL + next_account_index bigint DEFAULT 0 NOT NULL, + single_seed smallint DEFAULT 1 NOT NULL ); @@ -1917,6 +1918,11 @@ ALTER TABLE ONLY test_chat_schema.wallet_seeds +ALTER TABLE ONLY test_chat_schema.wallet_seeds + ADD CONSTRAINT wallet_seeds_single_seed_key UNIQUE (single_seed); + + + ALTER TABLE ONLY test_chat_schema.xftp_file_descriptions ADD CONSTRAINT xftp_file_descriptions_pkey PRIMARY KEY (file_descr_id); 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 5255241bfd..36242b9647 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs @@ -5,28 +5,23 @@ module Simplex.Chat.Store.SQLite.Migrations.M20260908_wallet_seeds where import Database.SQLite.Simple (Query) import Database.SQLite.Simple.QQ (sql) --- | Wallet seeds: BIP-39 entropy, one or more per database. --- --- The schema allows several seeds, with each chat profile bound to exactly one --- of them plus its own BIP-44 account index. Only the single-seed case is --- reachable today. m20260908_wallet_seeds :: Query m20260908_wallet_seeds = [sql| CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes - -- High-water mark for account allocation. Deliberately not derived from - -- MAX(users.wallet_account_index): after recovery from the phrase alone that - -- table is empty while accounts 0..N already hold names on chain, so a new - -- profile would silently reuse a recovered account's keys. - next_account_index INTEGER NOT NULL DEFAULT 0 + -- Known issue: after importing a phrase this starts at 0, so a recovered + -- device can re-issue an account that already owns names. A recovery scan + -- will raise it. + next_account_index INTEGER NOT NULL DEFAULT 0, + -- one key per device for now; drop when several are supported + single_seed INTEGER NOT NULL DEFAULT 1 UNIQUE ) STRICT; ALTER TABLE users ADD COLUMN wallet_seed_id INTEGER REFERENCES wallet_seeds ON DELETE RESTRICT; ALTER TABLE users ADD COLUMN wallet_account_index INTEGER; --- required: the .lint check enforces an index on every foreign key CREATE INDEX idx_users_wallet_seed_id ON users(wallet_seed_id); |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 7b616ebd3b..fa44bfe6fe 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -857,11 +857,12 @@ CREATE TABLE rcv_roster_transfers( CREATE TABLE wallet_seeds( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes - -- High-water mark for account allocation. Deliberately not derived from - -- MAX(users.wallet_account_index): after recovery from the phrase alone that - -- table is empty while accounts 0..N already hold names on chain, so a new - -- profile would silently reuse a recovered account's keys. -next_account_index INTEGER NOT NULL DEFAULT 0 + -- Known issue: after importing a phrase this starts at 0, so a recovered + -- device can re-issue an account that already owns names. A recovery scan + -- will raise it. + next_account_index INTEGER NOT NULL DEFAULT 0, + -- one key per device for now; drop when several are supported + single_seed INTEGER NOT NULL DEFAULT 1 UNIQUE ) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( display_name, diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index 32eb213d10..ac1ef938fb 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -2,39 +2,42 @@ {-# LANGUAGE LambdaCase #-} {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} +{-# LANGUAGE QuasiQuotes #-} --- | Persistence for wallet seeds and per-profile accounts. --- --- The schema holds several seeds and binds each chat profile to one of them --- plus its own account index. One seed per device is reachable today, so --- 'deviceSeed' is the seed, and 'getOrCreateAccountRef' creates it on first use. module Simplex.Chat.Store.Wallets - ( deviceSeed, - boundAccount, + ( getDeviceSeed, + getBoundAccount, + getSeedAccounts, getOrCreateAccountRef, + importSeed, + deleteSeed, ) where import Data.ByteString (ByteString) import Data.Int (Int64) +import Data.Maybe (isJust) +import Data.Text (Text) import Simplex.Chat.Store.Shared (insertedRowId) import Simplex.Chat.Types (User (..)) import Simplex.Chat.Wallet (AccountIndex, AccountRef (..), SeedId (..), WalletSeed (..)) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) +import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) 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 = SeedId sId, wsEntropy = seed} --- | The seed on this device, or Nothing if the wallet has never been used. -deviceSeed :: DB.Connection -> IO (Maybe WalletSeed) -deviceSeed db = +getDeviceSeed :: DB.Connection -> IO (Maybe WalletSeed) +getDeviceSeed db = maybeFirstRow toSeed $ DB.query_ db "SELECT wallet_seed_id, seed FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1" @@ -43,14 +46,6 @@ getWalletSeed db (SeedId sId) = maybeFirstRow toSeed $ DB.query db "SELECT wallet_seed_id, seed FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) --- | Insert a seed. Callers generate the entropy; this module never does, so the --- DRG stays with the agent. -createWalletSeed :: DB.Connection -> ByteString -> IO WalletSeed -createWalletSeed db seed = do - DB.execute db "INSERT INTO wallet_seeds (seed) VALUES (?)" (Only seed) - sId <- insertedRowId db - pure WalletSeed {wsId = SeedId sId, wsEntropy = seed} - getAccountRef :: DB.Connection -> User -> IO (Maybe AccountRef) getAccountRef db User {userId} = do r <- @@ -67,39 +62,63 @@ bindAccount db User {userId} AccountRef {arSeedId = SeedId sId, arIndex} = "UPDATE users SET wallet_seed_id = ?, wallet_account_index = ? WHERE user_id = ?" (sId, fromIntegral arIndex :: Int64, userId) --- | The seed and account this profile is bound to, or Nothing if it has never --- used the wallet. Creates nothing: a profile is never given keys as a side --- effect of reading. -boundAccount :: DB.Connection -> User -> IO (Maybe (WalletSeed, AccountRef)) -boundAccount db user = +getBoundAccount :: DB.Connection -> User -> IO (Maybe (WalletSeed, AccountRef)) +getBoundAccount db user = getAccountRef db user >>= \case Nothing -> pure Nothing Just r -> fmap (\s -> (s, r)) <$> getWalletSeed db (arSeedId r) --- | Bind this profile to the device's seed, creating that seed from @mkSeed@ if --- there is none yet. Every profile gets its own account index within it. -getOrCreateAccountRef :: DB.Connection -> User -> IO ByteString -> IO (WalletSeed, AccountRef) -getOrCreateAccountRef db user mkSeed = - boundAccount db user >>= \case - Just bound -> pure bound - Nothing -> do - s <- deviceSeed db >>= maybe (mkSeed >>= createWalletSeed db) pure - ix <- takeAccountIndex db (wsId s) - let r = AccountRef {arSeedId = wsId s, arIndex = ix} - bindAccount db user r - pure (s, r) +-- | Profiles bound to this seed: display name, account index, whether active, +-- whether hidden. +getSeedAccounts :: DB.Connection -> SeedId -> IO [(Text, AccountIndex, Bool, Bool)] +getSeedAccounts db (SeedId sId) = + map toRow + <$> DB.query + db + [sql| + SELECT local_display_name, wallet_account_index, active_user, view_pwd_hash + FROM users WHERE wallet_seed_id = ? ORDER BY wallet_account_index + |] + (Only sId) + where + toRow (n, ix, BI active, pwdHash) = (n, fromIntegral (ix :: Int64), active, isJust (pwdHash :: Maybe ByteString)) --- | Take the next account index and advance the seed's high-water mark. --- --- The mark is stored rather than computed as @MAX(users.wallet_account_index)@, --- because after recovery from the phrase alone the @users@ table is empty while --- accounts @0..N@ already hold names on chain. Computing it would hand the first --- newly created profile index 0 and, with it, a recovered account's keys. +-- | Bind this profile to the device seed, creating it from @entropy@ if there +-- is none. +getOrCreateAccountRef :: DB.Connection -> User -> ByteString -> IO (WalletSeed, AccountRef) +getOrCreateAccountRef db user entropy = + getBoundAccount db user >>= \case + Just bound -> pure bound + Nothing -> getDeviceSeed db >>= maybe (createWalletSeed db entropy) pure >>= bindNewAccount db user + +-- | Nothing if the device already has a key. One transaction, so a phrase +-- cannot be discarded in favour of a key created meanwhile; single_seed is +-- UNIQUE, so a concurrent insert cannot add a second key either. +importSeed :: DB.Connection -> User -> ByteString -> IO (Maybe (WalletSeed, AccountRef)) +importSeed db user entropy = + getDeviceSeed db >>= \case + Just _ -> pure Nothing + Nothing -> Just <$> (createWalletSeed db entropy >>= bindNewAccount db user) + +bindNewAccount :: DB.Connection -> User -> WalletSeed -> IO (WalletSeed, AccountRef) +bindNewAccount db user s = do + ix <- takeAccountIndex db (wsId s) + let r = AccountRef {arSeedId = wsId s, arIndex = ix} + bindAccount db user r + pure (s, r) + +createWalletSeed :: DB.Connection -> ByteString -> IO WalletSeed +createWalletSeed db entropy = do + DB.execute db "INSERT INTO wallet_seeds (seed) VALUES (?)" (Only entropy) + sId <- insertedRowId db + pure WalletSeed {wsId = SeedId sId, wsEntropy = entropy} + +-- | Incremented in SQL so that concurrent purchases cannot be handed the same +-- account, and read back inside the same transaction. takeAccountIndex :: DB.Connection -> SeedId -> IO AccountIndex takeAccountIndex db sId@(SeedId sId') = do - ix <- getNextAccountIndex db sId - DB.execute db "UPDATE wallet_seeds SET next_account_index = ? WHERE wallet_seed_id = ?" (fromIntegral ix + 1 :: Int64, sId') - pure ix + DB.execute db "UPDATE wallet_seeds SET next_account_index = next_account_index + 1 WHERE wallet_seed_id = ?" (Only sId') + subtract 1 <$> getNextAccountIndex db sId getNextAccountIndex :: DB.Connection -> SeedId -> IO AccountIndex getNextAccountIndex db (SeedId sId) = @@ -107,3 +126,9 @@ getNextAccountIndex db (SeedId sId) = <$> ( maybeFirstRow fromOnly $ DB.query db "SELECT next_account_index FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) ) + +-- | Profiles are unbound first: the foreign key is ON DELETE RESTRICT. +deleteSeed :: DB.Connection -> SeedId -> IO () +deleteSeed db (SeedId sId) = do + DB.execute db "UPDATE users SET wallet_seed_id = NULL, wallet_account_index = NULL WHERE wallet_seed_id = ?" (Only sId) + DB.execute db "DELETE FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index d50695e532..73c599eee9 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -189,16 +189,17 @@ 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 acc_ -> - ttyUser u $ case acc_ of - Just (acct, path, addr) -> - [ plain $ "wallet account " <> tshow acct, - plain $ "next name will be owned by " <> addr, - plain $ " at " <> path - ] - Nothing - | exists -> ["wallet key on this device, but this profile has no account - add one with " <> highlight' "/wallet create"] - | otherwise -> ["no wallet key on this device - create one with " <> highlight' "/wallet create"] + CRWallet u exists accs + | not exists -> ttyUser u ["no wallet key on this device - create one with " <> highlight' "/wallet create"] + | otherwise -> + ttyUser u $ + ("key 1" : concatMap accountRows accs) + <> ["this profile has no key yet - add one with " <> highlight' "/wallet create" | not (any (\(_, _, active, _) -> active) accs)] + where + accountRows (n, acct, active, keys) = + plain (" account " <> tshow acct <> " (" <> n <> (if active then ", active" else "") <> ")") + : zipWith nameRow [0 :: Int ..] keys + nameRow k (path, addr) = plain $ " name " <> tshow k <> " " <> path <> " " <> addr CRWalletPhrase u phrase -> ttyUser u [ "write this down - anyone who knows these words controls the names this key owns:", diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index c4a031a92b..e35efc7a98 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -2,12 +2,12 @@ -- | The wallet: BIP-39 seeds, and the keys derived from them. -- --- * __seed__ — BIP-39 entropy. Generic, /not/ name-specific. --- * __account__ — a profile's slot in a seed, BIP-44 account index @i@. --- * __name key__ — @m\/44'\/60'\/i'\/0\/k@: one key per name, at BIP-44 +-- * __seed__: BIP-39 entropy. Generic, /not/ name-specific. +-- * __account__: a profile's slot in a seed, BIP-44 account index @i@. +-- * __name key__: @m\/44'\/60'\/i'\/0\/k@: one key per name, at BIP-44 -- address index @k@ under the profile that buys it. This is what the -- registry records as the name's owner. --- * __wallet__ — this module: creation and derivation. +-- * __wallet__: this module, creation and derivation. -- -- One key per name, not one per profile. A per-profile key would mean exporting -- it hands over every name that profile owns, and would put every name's signed @@ -91,7 +91,7 @@ instance Show WalletAccount where show a = "WalletAccount " <> show (waRef a) <> " " -- | Fresh seed entropy. The caller stores it; this module never persists. --- A 25th-word passphrase is deliberately not used — it would be a second secret +-- A 25th-word passphrase is deliberately not used: 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 @@ -106,7 +106,7 @@ importRecoveryKey phrase = B39.mnemonicToEntropy <$> B39.parseMnemonic phrase recoveryKeyPhrase :: WalletSeed -> Either String ByteString recoveryKeyPhrase s = B39.mnemonicPhrase <$> B39.entropyToMnemonic (wsEntropy s) --- | @m\/44'\/60'\/i'\/0\/k@ — the standard BIP-44 layout, with the profile at +-- | @m\/44'\/60'\/i'\/0\/k@ is the standard BIP-44 layout, with the profile at -- the account level and the name at the address level. Nothing here is a custom -- path, so profile @i@'s names are the account list an ordinary Ethereum wallet -- would show for that account. diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 9c39797dc7..3566fe353d 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -6,11 +6,10 @@ module WalletTests where import ChatClient import ChatTests.DBUtils import ChatTests.Utils -import Control.Monad (replicateM_) import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Either (isLeft) -import Simplex.Chat.Help (walletHelpInfo) +import Data.List (intersect, nub) import Simplex.Chat.Wallet (SeedId (..), WalletSeed (..), accountAddress, deriveNameKey, importRecoveryKey, recoveryKeyPhrase, renderNameKeyPath) import Test.Hspec hiding (it) import qualified Test.Hspec as Hspec @@ -49,19 +48,21 @@ walletDerivationTests = do walletTests :: SpecWith TestParams walletTests = do - it "creates no key until asked, then shows the next name's address" testWalletCreate - it "the key and the address come back after a restart" testWalletPersists - it "a second profile gets its own account" testWalletSecondProfile + it "creates no key until asked, then shows the derived addresses" testWalletCreate + it "the key and the addresses come back after a restart" testWalletPersists + it "a second profile gets its own account, on the same key" testWalletSecondProfile it "imports a phrase, exports it, and refuses a second import" testWalletImport + it "deletes the key only with the last word of the phrase" testWalletDelete --- | The address a name would be bought at, and the path to reach it from the --- phrase. Returned so tests can compare addresses without pinning a random one. -nextNameAddress :: HasCallStack => TestCC -> Int -> IO String -nextNameAddress cc acct = do - cc <## ("wallet account " <> show acct) - addr <- getTermLine cc - cc <## (" at m/44'/60'/" <> show acct <> "'/0/0") - pure addr +-- | The derivation path and address of each name shown for a profile's account. +accountRows :: HasCallStack => TestCC -> String -> Int -> IO [(String, String)] +accountRows cc profile acct = do + cc <## (" account " <> show acct <> " (" <> profile <> ")") + mapM (\_ -> nameRow <$> getTermLine cc) [0 .. 1 :: Int] + where + nameRow l = case words l of + ["name", _, path, addr] -> (path, addr) + _ -> error $ "unexpected wallet row: " <> l testWalletCreate :: HasCallStack => TestParams -> IO () testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -71,48 +72,75 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/wallet" alice <## "no wallet key on this device - create one with /wallet create" alice ##> "/wallet export" - alice <## "bad chat command: no wallet key on this device" + alice <## "bad chat command: no wallet key for this profile - create one with /wallet create" alice ##> "/wallet create" - _ <- nextNameAddress alice 0 - alice ##> "/help wallet" - alice <## "Your wallet key:" - replicateM_ (length walletHelpInfo - 1) (getTermLine alice) + alice <## "key 1" + rows <- accountRows alice "alice, active" 0 + -- one key per name: the two addresses differ and sit at consecutive indices + map fst rows `shouldBe` ["m/44'/60'/0'/0/0", "m/44'/60'/0'/0/1"] + length (nub $ map snd rows) `shouldBe` 2 testWalletPersists :: HasCallStack => TestParams -> IO () testWalletPersists ps = do - addr <- withNewTestChat ps "alice" aliceProfile $ \alice -> do + rows <- withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/wallet create" - nextNameAddress alice 0 + alice <## "key 1" + accountRows alice "alice, active" 0 -- same database, new session: the seed has to come back from the DB, or the -- name bought at that address is unreachable withTestChat ps "alice" $ \alice -> do alice ##> "/wallet" - addr' <- nextNameAddress alice 0 - addr' `shouldBe` addr + alice <## "key 1" + rows' <- accountRows alice "alice, active" 0 + rows' `shouldBe` rows testWalletSecondProfile :: HasCallStack => TestParams -> IO () testWalletSecondProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/wallet create" - addr <- nextNameAddress alice 0 + alice <## "key 1" + rows <- accountRows alice "alice, active" 0 alice ##> "/create user alisa" showActiveUser alice "alisa" alice ##> "/wallet" - alice <## "wallet key on this device, but this profile has no account - add one with /wallet create" + alice <## "key 1" + _ <- accountRows alice "alice" 0 + alice <## "this profile has no key yet - add one with /wallet create" -- the same key, a different account, so the two profiles do not share names alice ##> "/wallet create" - addr' <- nextNameAddress alice 1 - addr' `shouldNotBe` addr + alice <## "key 1" + _ <- accountRows alice "alice" 0 + rows' <- accountRows alice "alisa, active" 1 + null (map snd rows `intersect` map snd rows') `shouldBe` True testWalletImport :: HasCallStack => TestParams -> IO () testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/wallet import " <> B.unpack testPhrase) - alice <## "wallet account 0" - alice <## "next name will be owned by 0x9858EfFD232B4033E47d90003D41EC34EcaEda94" - alice <## " at m/44'/60'/0'/0/0" + alice <## "key 1" + alice <## " account 0 (alice, active)" + alice <## " name 0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + alice <## " name 1 m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" alice ##> "/wallet export" alice <## "write this down - anyone who knows these words controls the names this key owns:" alice <## (" " <> B.unpack testPhrase) - -- one key per device: a second would make the address shown depend on which + -- one key per device: a second would make the addresses shown depend on which -- key was picked alice ##> ("/wallet import " <> 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 import " <> B.unpack (B.unwords $ replicate 12 "abandon")) + alice <## "bad chat command: bad recovery phrase" + +testWalletDelete :: HasCallStack => TestParams -> IO () +testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> ("/wallet import " <> B.unpack testPhrase) + alice <## "key 1" + _ <- accountRows alice "alice, active" 0 + alice ##> "/wallet delete abandon" + alice <## "bad chat command: to confirm, pass the last word of the recovery phrase" + alice ##> "/wallet delete about" + alice <## "no wallet key on this device - create one with /wallet create" + -- deleting unbinds the profile, so a real phrase can now be imported + alice ##> ("/wallet import " <> B.unpack testPhrase) + alice <## "key 1" + _ <- accountRows alice "alice, active" 0 + pure () From 70d4c128bd27c03d04e8ef36f1e54c82e23d59c8 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 9 Sep 2026 14:00:25 +0000 Subject: [PATCH 04/67] core: make the wallet an API, not a user-facing feature Names commands will use the wallet; users do not. The commands move to /_wallet, and the help section, its topic and the changelog entry are removed. The tests drive the API end to end. Comments cut to what the code does not say, dead exports removed. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- CHANGELOG.md | 9 -- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Help.hs | 27 +----- src/Simplex/Chat/Library/Commands.hs | 17 ++-- .../Migrations/M20260908_wallet_seeds.hs | 4 +- .../Migrations/M20260908_wallet_seeds.hs | 7 +- .../Store/SQLite/Migrations/chat_schema.sql | 7 +- src/Simplex/Chat/Store/Wallets.hs | 13 +-- src/Simplex/Chat/View.hs | 17 ++-- src/Simplex/Chat/Wallet.hs | 54 ++--------- tests/WalletTests.hs | 90 +++++++------------ 11 files changed, 69 insertions(+), 178 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 8a35756854..8487f0a53c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,14 +1,5 @@ # Release History -## Unreleased - -Wallet (in development, CLI only): creates the key that will own your SimpleX -names, so a name you buy later has an owner this app can still derive. One key -per device, one account per chat profile, each name at `m/44'/60'/'/0/` -(standard BIP-44, so your recovery phrase works in other wallets). `/wallet` -shows the addresses, `/wallet import` and `/wallet export` move the key, -`/wallet delete` removes it. You cannot buy a name yet. - ## v6.5 30 April, 2026 diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 37e900f8a9..94492007d3 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -325,7 +325,7 @@ data ChatController = ChatController contactMergeEnabled :: TVar Bool } -data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSRemote | HSSettings | HSDatabase | HSWallet +data HelpSection = HSMain | HSFiles | HSGroups | HSContacts | HSMyAddress | HSIncognito | HSMarkdown | HSMessages | HSRemote | HSSettings | HSDatabase deriving (Show) data ChatCommand diff --git a/src/Simplex/Chat/Help.hs b/src/Simplex/Chat/Help.hs index 81be9ce8ac..56ed65fb4b 100644 --- a/src/Simplex/Chat/Help.hs +++ b/src/Simplex/Chat/Help.hs @@ -14,7 +14,6 @@ module Simplex.Chat.Help markdownInfo, settingsInfo, databaseHelpInfo, - walletHelpInfo, ) where @@ -89,7 +88,7 @@ chatHelpInfo = green "Create your address: " <> highlight "/address", "", green "Other commands:", - indent <> highlight "/help " <> " - help on: " <> listHighlight ["groups", "contacts", "messages", "files", "address", "incognito", "remote", "settings", "db", "wallet"], + indent <> highlight "/help " <> " - help on: " <> listHighlight ["groups", "contacts", "messages", "files", "address", "incognito", "remote", "settings", "db"], indent <> highlight "/profile " <> " - show / update user profile", indent <> highlight "/delete " <> " - delete contact and all messages with them", indent <> highlight "/chats " <> " - most recent chats", @@ -219,30 +218,6 @@ myAddressHelpInfo = "The commands may be abbreviated: " <> listHighlight ["/ad", "/da", "/sa", "/ac", "/rc"] ] -walletHelpInfo :: [StyledString] -walletHelpInfo = - map - styleMarkdown - [ green "Wallet commands:", - indent <> highlight "/wallet " <> " - your key, and the addresses it derives", - indent <> highlight "/wallet create " <> " - create your key, or add this profile to it", - indent <> highlight "/wallet import " <> " - use a key you already have", - indent <> highlight "/wallet export " <> " - show your recovery phrase", - indent <> highlight "/wallet delete " <> " - delete the key, confirmed by the last word of the phrase", - "", - "Please note: this is in development. You cannot buy a name yet.", - "", - "One key per device, one account per chat profile. Each name gets its own", - "key at " <> highlight "m/44'/60'/'/0/" <> ". This is standard BIP-44, so your", - "phrase works in other wallets.", - "", - "The key is stored in the chat database. It is only encrypted if you set a", - "database passphrase with " <> highlight "/db encrypt" <> ", and it is included in " <> highlight "/db export" <> ".", - "", - "Anyone who has your recovery phrase controls your names. Keep it secret,", - "and keep a copy." - ] - incognitoHelpInfo :: [StyledString] incognitoHelpInfo = map diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 77018c177a..f80b1a23ca 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1496,7 +1496,7 @@ processChatCommand cxt nm = \case accs <- case seed_ of Nothing -> pure [] Just seed -> do - -- a hidden profile is left out, as it is by /users + -- hidden profiles are left out, as they are by /users as <- filter (\(_, _, active, hidden) -> active || not hidden) <$> withFastStore' (\db -> getSeedAccounts db (wsId seed)) forM as $ \(n, acct, active, _) -> do keys <- forM [0 .. walletNamesShown - 1] $ \k -> do @@ -5462,12 +5462,12 @@ withExpirationDate globalTTL chatItemTTL action = do let ttl = fromMaybe globalTTL chatItemTTL when (ttl > 0) $ action $ addUTCTime (-1 * fromIntegral ttl) currentTs --- | Name keys shown per profile by /wallet, to check derivation against other wallets. +-- | Name keys shown per profile, to check derivation against other wallets. walletNamesShown :: NameIndex walletNamesShown = 2 noKeyError :: String -noKeyError = "no wallet key for this profile - create one with /wallet create" +noKeyError = "no wallet key for this profile" chatCommandP :: Parser ChatCommand chatCommandP = @@ -5587,11 +5587,11 @@ 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" $> APIWalletCreate, - "/wallet import " *> (APIWalletImport <$> textP), - "/wallet export" $> APIWalletExport, - "/wallet delete " *> (APIWalletDelete <$> textP), - "/wallet" $> APIWallet, + "/_wallet create" $> APIWalletCreate, + "/_wallet import " *> (APIWalletImport <$> textP), + "/_wallet export" $> APIWalletExport, + "/_wallet delete " *> (APIWalletDelete <$> textP), + "/_wallet" $> APIWallet, "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP), "/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType), "/_call reject @" *> (APIRejectCall <$> A.decimal), @@ -5711,7 +5711,6 @@ chatCommandP = ("/help remote" <|> "/hr") $> ChatHelp HSRemote, ("/help settings" <|> "/hs") $> ChatHelp HSSettings, ("/help db" <|> "/hd") $> ChatHelp HSDatabase, - ("/help wallet" <|> "/hw") $> ChatHelp HSWallet, ("/help" <|> "/h") $> ChatHelp HSMain, ("/group" <|> "/g") *> (NewGroup <$> incognitoP <* A.space <* char_ '#' <*> groupProfile), "/_group " *> (APINewGroup <$> A.decimal <*> incognitoOnOffP <* A.space <*> jsonP), 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 04b8b3a46f..382aa79178 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs @@ -12,9 +12,9 @@ m20260908_wallet_seeds = CREATE TABLE wallet_seeds ( wallet_seed_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, seed BYTEA NOT NULL, - -- Known issue: see the SQLite migration. + -- see the SQLite migration next_account_index BIGINT NOT NULL DEFAULT 0, - -- one key per device for now; drop when several are supported + -- one key per device for now single_seed SMALLINT NOT NULL DEFAULT 1 UNIQUE ); 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 36242b9647..fcd14ee593 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs @@ -11,11 +11,10 @@ m20260908_wallet_seeds = CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes - -- Known issue: after importing a phrase this starts at 0, so a recovered - -- device can re-issue an account that already owns names. A recovery scan - -- will raise it. + -- known issue: after import this starts at 0, so a recovered device can + -- re-issue an account that already owns names next_account_index INTEGER NOT NULL DEFAULT 0, - -- one key per device for now; drop when several are supported + -- one key per device for now single_seed INTEGER NOT NULL DEFAULT 1 UNIQUE ) STRICT; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index fa44bfe6fe..aa8adc9714 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -857,11 +857,10 @@ CREATE TABLE rcv_roster_transfers( CREATE TABLE wallet_seeds( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes - -- Known issue: after importing a phrase this starts at 0, so a recovered - -- device can re-issue an account that already owns names. A recovery scan - -- will raise it. + -- known issue: after import this starts at 0, so a recovered device can + -- re-issue an account that already owns names next_account_index INTEGER NOT NULL DEFAULT 0, - -- one key per device for now; drop when several are supported + -- one key per device for now single_seed INTEGER NOT NULL DEFAULT 1 UNIQUE ) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index ac1ef938fb..90ddf96653 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -68,8 +68,6 @@ getBoundAccount db user = Nothing -> pure Nothing Just r -> fmap (\s -> (s, r)) <$> getWalletSeed db (arSeedId r) --- | Profiles bound to this seed: display name, account index, whether active, --- whether hidden. getSeedAccounts :: DB.Connection -> SeedId -> IO [(Text, AccountIndex, Bool, Bool)] getSeedAccounts db (SeedId sId) = map toRow @@ -83,17 +81,13 @@ getSeedAccounts db (SeedId sId) = where toRow (n, ix, BI active, pwdHash) = (n, fromIntegral (ix :: Int64), active, isJust (pwdHash :: Maybe ByteString)) --- | Bind this profile to the device seed, creating it from @entropy@ if there --- is none. getOrCreateAccountRef :: DB.Connection -> User -> ByteString -> IO (WalletSeed, AccountRef) getOrCreateAccountRef db user entropy = getBoundAccount db user >>= \case Just bound -> pure bound Nothing -> getDeviceSeed db >>= maybe (createWalletSeed db entropy) pure >>= bindNewAccount db user --- | Nothing if the device already has a key. One transaction, so a phrase --- cannot be discarded in favour of a key created meanwhile; single_seed is --- UNIQUE, so a concurrent insert cannot add a second key either. +-- | Nothing if the device already has a key. importSeed :: DB.Connection -> User -> ByteString -> IO (Maybe (WalletSeed, AccountRef)) importSeed db user entropy = getDeviceSeed db >>= \case @@ -113,8 +107,7 @@ createWalletSeed db entropy = do sId <- insertedRowId db pure WalletSeed {wsId = SeedId sId, wsEntropy = entropy} --- | Incremented in SQL so that concurrent purchases cannot be handed the same --- account, and read back inside the same transaction. +-- | Incremented in SQL, so two profiles cannot be handed the same account. takeAccountIndex :: DB.Connection -> SeedId -> IO AccountIndex takeAccountIndex db sId@(SeedId sId') = do DB.execute db "UPDATE wallet_seeds SET next_account_index = next_account_index + 1 WHERE wallet_seed_id = ?" (Only sId') @@ -127,7 +120,7 @@ getNextAccountIndex db (SeedId sId) = DB.query db "SELECT next_account_index FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) ) --- | Profiles are unbound first: the foreign key is ON DELETE RESTRICT. +-- | Profiles are unbound first, as the foreign key is ON DELETE RESTRICT. deleteSeed :: DB.Connection -> SeedId -> IO () deleteSeed db (SeedId sId) = do DB.execute db "UPDATE users SET wallet_seed_id = NULL, wallet_account_index = NULL WHERE wallet_seed_id = ?" (Only sId) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 73c599eee9..b770f46231 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -181,7 +181,6 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te HSRemote -> remoteHelpInfo HSSettings -> settingsInfo HSDatabase -> databaseHelpInfo - HSWallet -> walletHelpInfo CRWelcome user -> chatWelcome user CRContactsList u cs -> ttyUser u $ viewContactsList cs CRUserContactLink u UserContactLink {connLinkContact, addressSettings} -> ttyUser u $ connReqContact_ showFullLinks "Your chat address:" connLinkContact <> viewAddressSettings addressSettings @@ -190,21 +189,17 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te 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 accs - | not exists -> ttyUser u ["no wallet key on this device - create one with " <> highlight' "/wallet create"] + | not exists -> ttyUser u ["no wallet key"] | otherwise -> ttyUser u $ - ("key 1" : concatMap accountRows accs) - <> ["this profile has no key yet - add one with " <> highlight' "/wallet create" | not (any (\(_, _, active, _) -> active) accs)] + concatMap accountRows accs + <> ["this profile has no wallet key" | not (any (\(_, _, active, _) -> active) accs)] where accountRows (n, acct, active, keys) = - plain (" account " <> tshow acct <> " (" <> n <> (if active then ", active" else "") <> ")") + plain ("account " <> tshow acct <> " (" <> n <> (if active then ", active" else "") <> ")") : zipWith nameRow [0 :: Int ..] keys - nameRow k (path, addr) = plain $ " name " <> tshow k <> " " <> path <> " " <> addr - CRWalletPhrase u phrase -> - ttyUser u - [ "write this down - anyone who knows these words controls the names this key owns:", - plain $ " " <> phrase - ] + nameRow k (path, addr) = plain $ " name " <> tshow k <> " " <> path <> " " <> addr + CRWalletPhrase u phrase -> ttyUser u [plain phrase] 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 diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index e35efc7a98..8a15eb806b 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -1,39 +1,20 @@ {-# LANGUAGE OverloadedStrings #-} --- | The wallet: BIP-39 seeds, and the keys derived from them. +-- | BIP-39 seeds and the keys derived from them. -- --- * __seed__: BIP-39 entropy. Generic, /not/ name-specific. --- * __account__: a profile's slot in a seed, BIP-44 account index @i@. --- * __name key__: @m\/44'\/60'\/i'\/0\/k@: one key per name, at BIP-44 --- address index @k@ under the profile that buys it. This is what the --- registry records as the name's owner. --- * __wallet__: this module, creation and derivation. --- --- One key per name, not one per profile. A per-profile key would mean exporting --- it hands over every name that profile owns, and would put every name's signed --- record edits behind one shared nonce counter on the resolver. Both are avoided --- by giving each name its own address index. @k = 0@ is the profile's first --- name. --- --- The schema allows several seeds; a profile binds to exactly one plus its own --- account index. Only one seed per device is reachable today. --- --- This module is pure. Persistence lives in "Simplex.Chat.Store.Wallets". --- --- There is no signing here yet, so nothing can be bought or edited: this is the --- key material and the derivation only. +-- One key per name, not one per profile, so that exporting a name's key hands +-- over that name only. module Simplex.Chat.Wallet ( SeedId (..), WalletSeed (..), AccountIndex, NameIndex, AccountRef (..), - WalletAccount (..), + WalletAccount, newSeed, importRecoveryKey, recoveryKeyPhrase, deriveNameKey, - nameKeyPath, renderNameKeyPath, accountAddress, ) @@ -54,16 +35,12 @@ import Simplex.Messaging.Eth.Address (Address, addressFromPrivateKey) newtype SeedId = SeedId Int64 deriving (Eq, Ord, Show) --- | BIP-44 account index within a seed. One per chat profile. +-- | BIP-44 account index, one per chat profile. type AccountIndex = Word32 --- | BIP-44 address index within a profile account. One per name. +-- | BIP-44 address index, one per name. type NameIndex = Word32 --- | A seed, held as BIP-39 entropy. Stored in the chat database so it rides the --- existing archive export and Migrate-to-another-device flows. --- --- 'Show' is redacting: this is the root secret behind every name it owns. data WalletSeed = WalletSeed { wsId :: SeedId, wsEntropy :: ByteString @@ -73,14 +50,12 @@ data WalletSeed = WalletSeed instance Show WalletSeed where show s = "WalletSeed " <> show (wsId s) <> " " --- | What a chat profile stores: which seed, and which account index within it. data AccountRef = AccountRef { arSeedId :: SeedId, arIndex :: AccountIndex } deriving (Eq, Show) --- | A derived account: the reference plus the key it resolves to. data WalletAccount = WalletAccount { waRef :: AccountRef, waKey :: S.PrivateKey @@ -90,35 +65,23 @@ data WalletAccount = WalletAccount instance Show WalletAccount where show a = "WalletAccount " <> show (waRef a) <> " " --- | Fresh seed entropy. The caller stores it; this module never persists. --- A 25th-word passphrase is deliberately not used: it would be a second secret --- to back up. +-- | 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 --- | Import from a recovery phrase, validating the wordlist and the BIP-39 --- checksum. Returns the entropy; the caller persists it. importRecoveryKey :: ByteString -> Either String ByteString importRecoveryKey phrase = B39.mnemonicToEntropy <$> B39.parseMnemonic phrase --- | The phrase to show under "recovery key". Anyone who knows these words --- controls every name this seed owns, so the risk to state is theft, not loss. recoveryKeyPhrase :: WalletSeed -> Either String ByteString recoveryKeyPhrase s = B39.mnemonicPhrase <$> B39.entropyToMnemonic (wsEntropy s) --- | @m\/44'\/60'\/i'\/0\/k@ is the standard BIP-44 layout, with the profile at --- the account level and the name at the address level. Nothing here is a custom --- path, so profile @i@'s names are the account list an ordinary Ethereum wallet --- would show for that account. +-- | Standard BIP-44, so the phrase reaches the same addresses in other wallets. nameKeyPath :: AccountIndex -> NameIndex -> [Word32] nameKeyPath acc nm = [B32.hardened 44, B32.hardened 60, B32.hardened acc, 0, nm] --- | The path a name's key was derived at, for display. Users need it only to --- import a single name into a third-party wallet. renderNameKeyPath :: AccountIndex -> NameIndex -> Text renderNameKeyPath acc nm = decodeLatin1 . B32.renderPath $ nameKeyPath acc nm --- | Derive the key that owns one name. deriveNameKey :: WalletSeed -> AccountIndex -> NameIndex -> Either String WalletAccount deriveNameKey s acc nm = do m <- B39.entropyToMnemonic (wsEntropy s) @@ -126,6 +89,5 @@ deriveNameKey s acc nm = do xk <- B32.derivePath master (nameKeyPath acc nm) pure WalletAccount {waRef = AccountRef {arSeedId = wsId s, arIndex = acc}, waKey = B32.xkKey xk} --- | The Ethereum address that owns the name this key was derived for. accountAddress :: WalletAccount -> Address accountAddress = addressFromPrivateKey . waKey diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 3566fe353d..644b3cea3e 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -14,22 +14,17 @@ import Simplex.Chat.Wallet (SeedId (..), WalletSeed (..), accountAddress, derive import Test.Hspec hiding (it) import qualified Test.Hspec as Hspec --- | The BIP-39 test vector every wallet ships with, so the addresses below can --- be checked against MetaMask and Ledger Live. +-- | 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" testSeed :: WalletSeed testSeed = WalletSeed {wsId = SeedId 1, wsEntropy = either error id $ importRecoveryKey testPhrase} --- | Derivation is the part other wallets have to agree with, so it is pinned --- here rather than left to the end-to-end tests. walletDerivationTests :: Spec walletDerivationTests = do - -- Name keys are plain BIP-44: profile 0's names are exactly MetaMask's - -- account list (m/44'/60'/0'/0/k), and each profile's first name is the - -- matching Ledger Live account (m/44'/60'/i'/0/0). That is what lets an owner - -- move a single name into another wallet. + -- profile 0's names are MetaMask's account list, and each profile's first + -- name is the matching Ledger Live account Hspec.it "name keys line up with other wallets' derivation" $ do let addrOf i k = either error (show . accountAddress) (deriveNameKey testSeed i k) -- MetaMask accounts 1 and 2 for this phrase @@ -42,22 +37,20 @@ walletDerivationTests = do renderNameKeyPath 2 7 `shouldBe` "m/44'/60'/2'/0/7" Hspec.it "round-trips the phrase it was imported from" $ recoveryKeyPhrase testSeed `shouldBe` Right testPhrase - -- Without the checksum a mistyped word imports a key that owns nothing. Hspec.it "refuses a phrase with a bad checksum" $ importRecoveryKey (B.unwords $ replicate 12 "abandon") `shouldSatisfy` isLeft walletTests :: SpecWith TestParams walletTests = do - it "creates no key until asked, then shows the derived addresses" testWalletCreate + it "creates no key until asked, then derives addresses" testWalletCreate it "the key and the addresses come back after a restart" testWalletPersists it "a second profile gets its own account, on the same key" testWalletSecondProfile it "imports a phrase, exports it, and refuses a second import" testWalletImport it "deletes the key only with the last word of the phrase" testWalletDelete --- | The derivation path and address of each name shown for a profile's account. accountRows :: HasCallStack => TestCC -> String -> Int -> IO [(String, String)] accountRows cc profile acct = do - cc <## (" account " <> show acct <> " (" <> profile <> ")") + cc <## ("account " <> show acct <> " (" <> profile <> ")") mapM (\_ -> nameRow <$> getTermLine cc) [0 .. 1 :: Int] where nameRow l = case words l of @@ -66,81 +59,66 @@ accountRows cc profile acct = do testWalletCreate :: HasCallStack => TestParams -> IO () testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> "/wallet" - alice <## "no wallet key on this device - create one with /wallet create" - -- asked again: still nothing, and nothing was created by asking - alice ##> "/wallet" - alice <## "no wallet key on this device - create one with /wallet create" - alice ##> "/wallet export" - alice <## "bad chat command: no wallet key for this profile - create one with /wallet create" - alice ##> "/wallet create" - alice <## "key 1" + alice ##> "/_wallet" + alice <## "no wallet key" + -- asking creates nothing + alice ##> "/_wallet" + alice <## "no wallet key" + alice ##> "/_wallet export" + alice <## "bad chat command: no wallet key for this profile" + alice ##> "/_wallet create" rows <- accountRows alice "alice, active" 0 - -- one key per name: the two addresses differ and sit at consecutive indices map fst rows `shouldBe` ["m/44'/60'/0'/0/0", "m/44'/60'/0'/0/1"] length (nub $ map snd rows) `shouldBe` 2 testWalletPersists :: HasCallStack => TestParams -> IO () testWalletPersists ps = do rows <- withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> "/wallet create" - alice <## "key 1" + alice ##> "/_wallet create" accountRows alice "alice, active" 0 - -- same database, new session: the seed has to come back from the DB, or the - -- name bought at that address is unreachable + -- same database, new session: a name bought at that address must stay reachable withTestChat ps "alice" $ \alice -> do - alice ##> "/wallet" - alice <## "key 1" + alice ##> "/_wallet" rows' <- accountRows alice "alice, active" 0 rows' `shouldBe` rows testWalletSecondProfile :: HasCallStack => TestParams -> IO () testWalletSecondProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> "/wallet create" - alice <## "key 1" + alice ##> "/_wallet create" rows <- accountRows alice "alice, active" 0 alice ##> "/create user alisa" showActiveUser alice "alisa" - alice ##> "/wallet" - alice <## "key 1" + alice ##> "/_wallet" _ <- accountRows alice "alice" 0 - alice <## "this profile has no key yet - add one with /wallet create" - -- the same key, a different account, so the two profiles do not share names - alice ##> "/wallet create" - alice <## "key 1" + alice <## "this profile has no wallet key" + alice ##> "/_wallet create" _ <- accountRows alice "alice" 0 rows' <- accountRows alice "alisa, active" 1 null (map snd rows `intersect` map snd rows') `shouldBe` True testWalletImport :: HasCallStack => TestParams -> IO () testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/wallet import " <> B.unpack testPhrase) - alice <## "key 1" - alice <## " account 0 (alice, active)" - alice <## " name 0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94" - alice <## " name 1 m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" - alice ##> "/wallet export" - alice <## "write this down - anyone who knows these words controls the names this key owns:" - alice <## (" " <> B.unpack testPhrase) - -- one key per device: a second would make the addresses shown depend on which - -- key was picked - alice ##> ("/wallet import " <> B.unpack testPhrase) + alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice <## "account 0 (alice, active)" + alice <## " name 0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + alice <## " name 1 m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" + alice ##> "/_wallet export" + alice <## B.unpack testPhrase + alice ##> ("/_wallet import " <> 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 import " <> B.unpack (B.unwords $ replicate 12 "abandon")) + alice ##> ("/_wallet import " <> B.unpack (B.unwords $ replicate 12 "abandon")) alice <## "bad chat command: bad recovery phrase" testWalletDelete :: HasCallStack => TestParams -> IO () testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/wallet import " <> B.unpack testPhrase) - alice <## "key 1" + alice ##> ("/_wallet import " <> B.unpack testPhrase) _ <- accountRows alice "alice, active" 0 - alice ##> "/wallet delete abandon" + alice ##> "/_wallet delete abandon" alice <## "bad chat command: to confirm, pass the last word of the recovery phrase" - alice ##> "/wallet delete about" - alice <## "no wallet key on this device - create one with /wallet create" - -- deleting unbinds the profile, so a real phrase can now be imported - alice ##> ("/wallet import " <> B.unpack testPhrase) - alice <## "key 1" + alice ##> "/_wallet delete about" + alice <## "no wallet key" + -- deleting unbinds the profile, so a key can be imported again + alice ##> ("/_wallet import " <> B.unpack testPhrase) _ <- accountRows alice "alice, active" 0 pure () From e5d18bd59e31459e18c05b5aaabf4669ec7c1a0c Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Wed, 9 Sep 2026 16:06:34 +0000 Subject: [PATCH 05/67] core: wallet export and delete act on the device key The key is one per device, so both commands read it directly instead of going through the active profile's account. A profile without an account of its own can now export, and the delete error says that the key goes for every profile on the device. The confirmation word is lowercased, as the phrase is when imported. Two known limitations are written down: account indexes restart at 0 after an import, so profiles are bound in the order they ask for a key, and the gap left by a hidden profile in the printed indexes shows that it exists. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- src/Simplex/Chat/Library/Commands.hs | 13 +++++++------ .../SQLite/Migrations/M20260908_wallet_seeds.hs | 3 ++- .../Chat/Store/SQLite/Migrations/chat_schema.sql | 3 ++- src/Simplex/Chat/Store/Wallets.hs | 1 - tests/WalletTests.hs | 11 ++++++++--- 5 files changed, 19 insertions(+), 12 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index f80b1a23ca..08aca5dbf3 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -58,7 +58,7 @@ 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 (deleteSeed, getBoundAccount, getDeviceSeed, getOrCreateAccountRef, getSeedAccounts, importSeed) +import Simplex.Chat.Store.Wallets (deleteSeed, getDeviceSeed, getOrCreateAccountRef, getSeedAccounts, importSeed) import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), accountAddress, deriveNameKey, importRecoveryKey, newSeed, recoveryKeyPhrase, renderNameKeyPath) import Simplex.Chat.Call import Simplex.Chat.Controller @@ -1496,7 +1496,8 @@ processChatCommand cxt nm = \case accs <- case seed_ of Nothing -> pure [] Just seed -> do - -- hidden profiles are left out, as they are by /users + -- hidden profiles are left out, as they are by /users, but the gap in + -- account indexes still shows that one exists as <- filter (\(_, _, active, hidden) -> active || not hidden) <$> withFastStore' (\db -> getSeedAccounts db (wsId seed)) forM as $ \(n, acct, active, _) -> do keys <- forM [0 .. walletNamesShown - 1] $ \k -> do @@ -1515,17 +1516,17 @@ processChatCommand cxt nm = \case when (isNothing r) $ throwCmdError "this device already has a wallet key" processChatCommand cxt nm APIWallet APIWalletExport -> withUser $ \user -> do - (seed, _) <- withFastStore' (\db -> getBoundAccount db user) >>= maybe (throwCmdError noKeyError) pure + seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure phrase <- either (throwCmdError . ("wallet: " <>)) pure $ recoveryKeyPhrase seed pure $ CRWalletPhrase user (safeDecodeUtf8 phrase) APIWalletDelete confirmWord -> withUser $ \user -> do seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure phrase <- either (throwCmdError . ("wallet: " <>)) pure $ recoveryKeyPhrase seed case reverse . T.words $ safeDecodeUtf8 phrase of - w : _ | w == confirmWord -> do + w : _ | w == T.toLower confirmWord -> do withFastStore' $ \db -> deleteSeed db (wsId seed) processChatCommand cxt nm APIWallet - _ -> throwCmdError "to confirm, pass the last word of the recovery phrase" + _ -> throwCmdError "this deletes the wallet key for all profiles on this device, to confirm pass the last word of the recovery phrase" APISendCallInvitation contactId callType -> withUser $ \user -> do -- party initiating call ct <- withFastStore $ \db -> getContact db cxt user contactId @@ -5467,7 +5468,7 @@ walletNamesShown :: NameIndex walletNamesShown = 2 noKeyError :: String -noKeyError = "no wallet key for this profile" +noKeyError = "no wallet key on this device" chatCommandP :: Parser ChatCommand chatCommandP = 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 fcd14ee593..4e7235ff40 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs @@ -12,7 +12,8 @@ CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes -- known issue: after import this starts at 0, so a recovered device can - -- re-issue an account that already owns names + -- re-issue an account that already owns names, and profiles are bound in + -- the order they ask for a key, not the order they had next_account_index INTEGER NOT NULL DEFAULT 0, -- one key per device for now single_seed INTEGER NOT NULL DEFAULT 1 UNIQUE diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index aa8adc9714..7a3b18af4d 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -858,7 +858,8 @@ CREATE TABLE wallet_seeds( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes -- known issue: after import this starts at 0, so a recovered device can - -- re-issue an account that already owns names + -- re-issue an account that already owns names, and profiles are bound in + -- the order they ask for a key, not the order they had next_account_index INTEGER NOT NULL DEFAULT 0, -- one key per device for now single_seed INTEGER NOT NULL DEFAULT 1 UNIQUE diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index 90ddf96653..b7a41a1e79 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -6,7 +6,6 @@ module Simplex.Chat.Store.Wallets ( getDeviceSeed, - getBoundAccount, getSeedAccounts, getOrCreateAccountRef, importSeed, diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 644b3cea3e..3f90ea8852 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -65,7 +65,7 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet" alice <## "no wallet key" alice ##> "/_wallet export" - alice <## "bad chat command: no wallet key for this profile" + alice <## "bad chat command: no wallet key on this device" alice ##> "/_wallet create" rows <- accountRows alice "alice, active" 0 map fst rows `shouldBe` ["m/44'/60'/0'/0/0", "m/44'/60'/0'/0/1"] @@ -86,11 +86,16 @@ testWalletSecondProfile :: HasCallStack => TestParams -> IO () testWalletSecondProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create" rows <- accountRows alice "alice, active" 0 + alice ##> "/_wallet export" + phrase <- getTermLine alice alice ##> "/create user alisa" showActiveUser alice "alisa" alice ##> "/_wallet" _ <- accountRows alice "alice" 0 alice <## "this profile has no wallet key" + -- the key belongs to the device, so a profile without an account exports it too + alice ##> "/_wallet export" + alice <## phrase alice ##> "/_wallet create" _ <- accountRows alice "alice" 0 rows' <- accountRows alice "alisa, active" 1 @@ -115,8 +120,8 @@ testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet import " <> B.unpack testPhrase) _ <- accountRows alice "alice, active" 0 alice ##> "/_wallet delete abandon" - alice <## "bad chat command: to confirm, pass the last word of the recovery phrase" - alice ##> "/_wallet delete about" + alice <## "bad chat command: this deletes the wallet key for all profiles on this device, to confirm pass the last word of the recovery phrase" + alice ##> "/_wallet delete About" alice <## "no wallet key" -- deleting unbinds the profile, so a key can be imported again alice ##> ("/_wallet import " <> B.unpack testPhrase) From fe9c20370fabf4fe8869e216e66032dee05aceb3 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 10 Sep 2026 08:43:43 +0000 Subject: [PATCH 06/67] core: /_wallet delete takes no confirmation word The wallet is an API, and no other API command takes a typed confirmation: /_delete user takes the profile password. The word was also no barrier to a caller, which can read it from /_wallet export. Confirming belongs in the command that will wrap this one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- src/Simplex/Chat/Controller.hs | 4 ++-- src/Simplex/Chat/Library/Commands.hs | 12 ++++-------- tests/WalletTests.hs | 6 ++---- 3 files changed, 8 insertions(+), 14 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 94492007d3..7339c18200 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -421,7 +421,7 @@ data ChatCommand | APIWalletCreate | APIWalletImport {recoveryPhrase :: Text} | APIWalletExport - | APIWalletDelete {confirmWord :: Text} + | APIWalletDelete | APISendCallInvitation ContactId CallType | SendCallInvitation ContactName CallType | APIRejectCall ContactId @@ -751,7 +751,7 @@ allowRemoteCommand = \case APIWalletCreate -> False APIWalletImport _ -> False APIWalletExport -> False - APIWalletDelete _ -> False + APIWalletDelete -> False _ -> True data RelayConnectionResult = RelayConnectionResult diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 08aca5dbf3..7f6d41c61e 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1519,14 +1519,10 @@ processChatCommand cxt nm = \case seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure phrase <- either (throwCmdError . ("wallet: " <>)) pure $ recoveryKeyPhrase seed pure $ CRWalletPhrase user (safeDecodeUtf8 phrase) - APIWalletDelete confirmWord -> withUser $ \user -> do + APIWalletDelete -> withUser $ \_ -> do seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure - phrase <- either (throwCmdError . ("wallet: " <>)) pure $ recoveryKeyPhrase seed - case reverse . T.words $ safeDecodeUtf8 phrase of - w : _ | w == T.toLower confirmWord -> do - withFastStore' $ \db -> deleteSeed db (wsId seed) - processChatCommand cxt nm APIWallet - _ -> throwCmdError "this deletes the wallet key for all profiles on this device, to confirm pass the last word of the recovery phrase" + withFastStore' $ \db -> deleteSeed db (wsId seed) + processChatCommand cxt nm APIWallet APISendCallInvitation contactId callType -> withUser $ \user -> do -- party initiating call ct <- withFastStore $ \db -> getContact db cxt user contactId @@ -5591,7 +5587,7 @@ chatCommandP = "/_wallet create" $> APIWalletCreate, "/_wallet import " *> (APIWalletImport <$> textP), "/_wallet export" $> APIWalletExport, - "/_wallet delete " *> (APIWalletDelete <$> textP), + "/_wallet delete" $> APIWalletDelete, "/_wallet" $> APIWallet, "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP), "/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType), diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 3f90ea8852..cfce4383da 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -46,7 +46,7 @@ walletTests = do it "the key and the addresses come back after a restart" testWalletPersists it "a second profile gets its own account, on the same key" testWalletSecondProfile it "imports a phrase, exports it, and refuses a second import" testWalletImport - it "deletes the key only with the last word of the phrase" testWalletDelete + it "deletes the key, and a key can be imported again" testWalletDelete accountRows :: HasCallStack => TestCC -> String -> Int -> IO [(String, String)] accountRows cc profile acct = do @@ -119,9 +119,7 @@ testWalletDelete :: HasCallStack => TestParams -> IO () testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet import " <> B.unpack testPhrase) _ <- accountRows alice "alice, active" 0 - alice ##> "/_wallet delete abandon" - alice <## "bad chat command: this deletes the wallet key for all profiles on this device, to confirm pass the last word of the recovery phrase" - alice ##> "/_wallet delete About" + alice ##> "/_wallet delete" alice <## "no wallet key" -- deleting unbinds the profile, so a key can be imported again alice ##> ("/_wallet import " <> B.unpack testPhrase) From e7f5908d01d8b71cc23bf2e626e8e5e484d16ba1 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 10 Sep 2026 09:53:26 +0000 Subject: [PATCH 07/67] core: name the unique index on wallet_seeds The one key per device rule is meant to be lifted later. SQLite cannot drop a column with an inline UNIQUE, or its automatic index, so lifting it would have meant rebuilding the table. As a named index it is a DROP INDEX and a DROP COLUMN. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- .../Store/Postgres/Migrations/M20260908_wallet_seeds.hs | 3 ++- .../Chat/Store/Postgres/Migrations/chat_schema.sql | 9 ++++----- .../Store/SQLite/Migrations/M20260908_wallet_seeds.hs | 3 ++- src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql | 3 ++- src/Simplex/Chat/Wallet.hs | 4 ++-- 5 files changed, 12 insertions(+), 10 deletions(-) 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 382aa79178..a5117f0873 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs @@ -15,12 +15,13 @@ CREATE TABLE wallet_seeds ( -- see the SQLite migration next_account_index BIGINT NOT NULL DEFAULT 0, -- one key per device for now - single_seed SMALLINT NOT NULL DEFAULT 1 UNIQUE + single_seed SMALLINT NOT NULL DEFAULT 1 ); ALTER TABLE users ADD COLUMN wallet_seed_id BIGINT REFERENCES wallet_seeds ON DELETE RESTRICT; ALTER TABLE users ADD COLUMN wallet_account_index BIGINT; +CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON wallet_seeds(single_seed); CREATE INDEX idx_users_wallet_seed_id ON users(wallet_seed_id); |] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index b629dd814f..090e2ebf6d 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1918,11 +1918,6 @@ ALTER TABLE ONLY test_chat_schema.wallet_seeds -ALTER TABLE ONLY test_chat_schema.wallet_seeds - ADD CONSTRAINT wallet_seeds_single_seed_key UNIQUE (single_seed); - - - ALTER TABLE ONLY test_chat_schema.xftp_file_descriptions ADD CONSTRAINT xftp_file_descriptions_pkey PRIMARY KEY (file_descr_id); @@ -2676,6 +2671,10 @@ CREATE INDEX idx_users_wallet_seed_id ON test_chat_schema.users USING btree (wal +CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON test_chat_schema.wallet_seeds USING btree (single_seed); + + + CREATE INDEX idx_xftp_file_descriptions_user_id ON test_chat_schema.xftp_file_descriptions USING btree (user_id); 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 4e7235ff40..dfacc89385 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs @@ -16,12 +16,13 @@ CREATE TABLE wallet_seeds ( -- the order they ask for a key, not the order they had next_account_index INTEGER NOT NULL DEFAULT 0, -- one key per device for now - single_seed INTEGER NOT NULL DEFAULT 1 UNIQUE + single_seed INTEGER NOT NULL DEFAULT 1 ) STRICT; ALTER TABLE users ADD COLUMN wallet_seed_id INTEGER REFERENCES wallet_seeds ON DELETE RESTRICT; ALTER TABLE users ADD COLUMN wallet_account_index INTEGER; +CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON wallet_seeds(single_seed); CREATE INDEX idx_users_wallet_seed_id ON users(wallet_seed_id); |] diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 7a3b18af4d..04d830fee5 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -862,7 +862,7 @@ CREATE TABLE wallet_seeds( -- the order they ask for a key, not the order they had next_account_index INTEGER NOT NULL DEFAULT 0, -- one key per device for now - single_seed INTEGER NOT NULL DEFAULT 1 UNIQUE + single_seed INTEGER NOT NULL DEFAULT 1 ) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( display_name, @@ -1398,6 +1398,7 @@ CREATE INDEX idx_files_roster_transfer_id ON files(roster_transfer_id); 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 INDEX idx_users_wallet_seed_id ON users(wallet_seed_id); CREATE TRIGGER on_group_members_insert_update_summary AFTER INSERT ON group_members diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index 8a15eb806b..458a3e9a54 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -2,8 +2,8 @@ -- | BIP-39 seeds and the keys derived from them. -- --- One key per name, not one per profile, so that exporting a name's key hands --- over that name only. +-- Two layers: one account path per profile, and one key per name under it. A +-- per-profile key would hand over every name that profile owns. module Simplex.Chat.Wallet ( SeedId (..), WalletSeed (..), From db1b6413e0184b803dbd4f2f3a72a52d3122bc68 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 10 Sep 2026 10:11:29 +0000 Subject: [PATCH 08/67] core: export the secret of a single name key /_wallet export gives the seed mnemonic, /_wallet export gives the secret of one derived key, so a single name can be handed over without the seed. Both are what a wallet takes on import: the mnemonic as a recovery phrase, the secret as hex. The commands are named for what they return, APIWalletExportSeedMnemonic and APIWalletExportDerivedSecret, as the difference is which secret leaves the device. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- bots/src/API/Docs/Commands.hs | 3 ++- bots/src/API/Docs/Responses.hs | 3 ++- src/Simplex/Chat/Controller.hs | 11 +++++++---- src/Simplex/Chat/Library/Commands.hs | 13 +++++++++---- src/Simplex/Chat/View.hs | 3 ++- src/Simplex/Chat/Wallet.hs | 6 ++++++ tests/WalletTests.hs | 23 ++++++++++++++++++++++- 7 files changed, 50 insertions(+), 12 deletions(-) diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index c08e63fe0c..0be6b110a7 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -453,7 +453,8 @@ undocumentedCommands = "APIWallet", "APIWalletCreate", "APIWalletDelete", - "APIWalletExport", + "APIWalletExportDerivedSecret", + "APIWalletExportSeedMnemonic", "APIWalletImport", "CheckChatRunning", "ConfirmRemoteCtrl", diff --git a/bots/src/API/Docs/Responses.hs b/bots/src/API/Docs/Responses.hs index 913e57b432..9facd2fd3e 100644 --- a/bots/src/API/Docs/Responses.hs +++ b/bots/src/API/Docs/Responses.hs @@ -213,6 +213,7 @@ undocumentedResponses = "CRUserServersValidation", "CRVersionInfo", "CRWallet", - "CRWalletPhrase", + "CRWalletDerivedSecret", + "CRWalletSeedMnemonic", "CRWelcome" ] diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 7339c18200..d9b71084da 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -68,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 (AccountIndex) +import Simplex.Chat.Wallet (AccountIndex, NameIndex) import Simplex.Chat.Util (liftIOEither) import Simplex.FileTransfer.Description (FileDescriptionURI) import Simplex.Messaging.Server.Information (ServerPublicInfo) @@ -420,7 +420,8 @@ data ChatCommand | APIWallet | APIWalletCreate | APIWalletImport {recoveryPhrase :: Text} - | APIWalletExport + | APIWalletExportSeedMnemonic + | APIWalletExportDerivedSecret {accountIndex :: AccountIndex, nameIndex :: NameIndex} | APIWalletDelete | APISendCallInvitation ContactId CallType | SendCallInvitation ContactName CallType @@ -750,7 +751,8 @@ allowRemoteCommand = \case APIWallet -> False APIWalletCreate -> False APIWalletImport _ -> False - APIWalletExport -> False + APIWalletExportSeedMnemonic -> False + APIWalletExportDerivedSecret {} -> False APIWalletDelete -> False _ -> True @@ -854,7 +856,8 @@ data ChatResponse | CRServiceResponse {user :: User, responseData :: J.Object} | CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId} | CRWallet {user :: User, walletKeyExists :: Bool, walletAccounts :: [(Text, AccountIndex, Bool, [(Text, Text)])]} - | CRWalletPhrase {user :: User, recoveryPhrase :: Text} + | CRWalletSeedMnemonic {user :: User, recoveryPhrase :: Text} + | CRWalletDerivedSecret {user :: User, keyPath :: Text, address :: Text, derivedSecret :: 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]} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 7f6d41c61e..6f5845c1e7 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -59,7 +59,7 @@ 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 (deleteSeed, getDeviceSeed, getOrCreateAccountRef, getSeedAccounts, importSeed) -import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), accountAddress, deriveNameKey, importRecoveryKey, newSeed, recoveryKeyPhrase, renderNameKeyPath) +import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), accountAddress, accountSecret, deriveNameKey, importRecoveryKey, newSeed, recoveryKeyPhrase, renderNameKeyPath) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -1515,10 +1515,14 @@ processChatCommand cxt nm = \case r <- withFastStore' $ \db -> importSeed db user entropy when (isNothing r) $ throwCmdError "this device already has a wallet key" processChatCommand cxt nm APIWallet - APIWalletExport -> withUser $ \user -> do + APIWalletExportSeedMnemonic -> withUser $ \user -> do seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure phrase <- either (throwCmdError . ("wallet: " <>)) pure $ recoveryKeyPhrase seed - pure $ CRWalletPhrase user (safeDecodeUtf8 phrase) + pure $ CRWalletSeedMnemonic user (safeDecodeUtf8 phrase) + APIWalletExportDerivedSecret acct nameIdx -> withUser $ \user -> do + seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure + acc <- either (throwCmdError . ("wallet: " <>)) pure $ deriveNameKey seed acct nameIdx + pure $ CRWalletDerivedSecret user (renderNameKeyPath acct nameIdx) (tshow $ accountAddress acc) (safeDecodeUtf8 $ accountSecret acc) APIWalletDelete -> withUser $ \_ -> do seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure withFastStore' $ \db -> deleteSeed db (wsId seed) @@ -5586,7 +5590,8 @@ chatCommandP = "/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP), "/_wallet create" $> APIWalletCreate, "/_wallet import " *> (APIWalletImport <$> textP), - "/_wallet export" $> APIWalletExport, + "/_wallet export " *> (APIWalletExportDerivedSecret <$> A.decimal <* A.space <*> A.decimal), + "/_wallet export" $> APIWalletExportSeedMnemonic, "/_wallet delete" $> APIWalletDelete, "/_wallet" $> APIWallet, "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP), diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index b770f46231..8f598340f1 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -199,7 +199,8 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te plain ("account " <> tshow acct <> " (" <> n <> (if active then ", active" else "") <> ")") : zipWith nameRow [0 :: Int ..] keys nameRow k (path, addr) = plain $ " name " <> tshow k <> " " <> path <> " " <> addr - CRWalletPhrase u phrase -> ttyUser u [plain phrase] + CRWalletSeedMnemonic u phrase -> ttyUser u [plain phrase] + CRWalletDerivedSecret u path addr secret -> ttyUser u [plain $ path <> " " <> addr <> " " <> 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 diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index 458a3e9a54..b7c78bd0d0 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -17,11 +17,13 @@ module Simplex.Chat.Wallet deriveNameKey, renderNameKeyPath, accountAddress, + accountSecret, ) where import Control.Concurrent.STM import Crypto.Random (ChaChaDRG) +import qualified Data.ByteArray.Encoding as BAE import Data.ByteString (ByteString) import Data.Int (Int64) import Data.Text (Text) @@ -91,3 +93,7 @@ deriveNameKey s acc nm = do accountAddress :: WalletAccount -> Address accountAddress = addressFromPrivateKey . waKey + +-- | Hex, as wallets take it when a key is imported on its own. +accountSecret :: WalletAccount -> ByteString +accountSecret a = "0x" <> BAE.convertToBase BAE.Base16 (S.unPrivateKey $ waKey a) diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index cfce4383da..e070732712 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -10,7 +10,7 @@ import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Either (isLeft) import Data.List (intersect, nub) -import Simplex.Chat.Wallet (SeedId (..), WalletSeed (..), accountAddress, deriveNameKey, importRecoveryKey, recoveryKeyPhrase, renderNameKeyPath) +import Simplex.Chat.Wallet (SeedId (..), WalletSeed (..), accountAddress, accountSecret, deriveNameKey, importRecoveryKey, recoveryKeyPhrase, renderNameKeyPath) import Test.Hspec hiding (it) import qualified Test.Hspec as Hspec @@ -32,6 +32,10 @@ walletDerivationTests = do addrOf 0 1 `shouldBe` "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" -- Ledger Live account 2 for this phrase addrOf 1 0 `shouldBe` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265" + Hspec.it "derives the same secret as other wallets" $ + -- MetaMask account 1 for this phrase, as exported by "Show private key" + either error (show . accountSecret) (deriveNameKey testSeed 0 0) + `shouldBe` "\"0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727\"" Hspec.it "renders the path a name key sits at" $ do renderNameKeyPath 0 0 `shouldBe` "m/44'/60'/0'/0/0" renderNameKeyPath 2 7 `shouldBe` "m/44'/60'/2'/0/7" @@ -46,6 +50,7 @@ walletTests = do it "the key and the addresses come back after a restart" testWalletPersists it "a second profile gets its own account, on the same key" testWalletSecondProfile it "imports a phrase, exports it, and refuses a second import" testWalletImport + it "exports the secret of any name key" testWalletExportDerivedSecret it "deletes the key, and a key can be imported again" testWalletDelete accountRows :: HasCallStack => TestCC -> String -> Int -> IO [(String, String)] @@ -115,6 +120,22 @@ testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet import " <> B.unpack (B.unwords $ replicate 12 "abandon")) alice <## "bad chat command: bad recovery phrase" +testWalletExportDerivedSecret :: HasCallStack => TestParams -> IO () +testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> ("/_wallet import " <> B.unpack testPhrase) + _ <- accountRows alice "alice, active" 0 + alice ##> "/_wallet export 0 0" + alice <## "m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727" + -- any path derives, whether or not a profile holds that account + alice ##> "/_wallet export 3 7" + l <- getTermLine alice + case words l of + [path, addr, secret] -> do + path `shouldBe` "m/44'/60'/3'/0/7" + length addr `shouldBe` 42 + length secret `shouldBe` 66 + _ -> error $ "unexpected export row: " <> l + testWalletDelete :: HasCallStack => TestParams -> IO () testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet import " <> B.unpack testPhrase) From ab50d9f4b1b4f104741b2ef3ca16434ff8fa8541 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 10 Sep 2026 11:15:02 +0000 Subject: [PATCH 09/67] core: reject a key index BIP-32 cannot harden A.decimal wraps into Word32 and hardened leaves anything at or above 2^31 alone, so /_wallet export 4294967296 0 returned account 0's secret under the path m/44'/60'/0'/0/0. Handing out a different account's key for a mistyped index is the wrong failure for this command. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- src/Simplex/Chat/Library/Commands.hs | 6 +++++- tests/WalletTests.hs | 3 +++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 6f5845c1e7..ad21a337e0 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -5590,7 +5590,7 @@ chatCommandP = "/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP), "/_wallet create" $> APIWalletCreate, "/_wallet import " *> (APIWalletImport <$> textP), - "/_wallet export " *> (APIWalletExportDerivedSecret <$> A.decimal <* A.space <*> A.decimal), + "/_wallet export " *> (APIWalletExportDerivedSecret <$> keyIndexP <* A.space <*> keyIndexP), "/_wallet export" $> APIWalletExportSeedMnemonic, "/_wallet delete" $> APIWalletDelete, "/_wallet" $> APIWallet, @@ -6134,6 +6134,10 @@ 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 + keyIndexP = do + i <- A.decimal :: Parser Integer + if i < 0x80000000 then pure (fromIntegral i) else fail "key index too large" displayNameP :: Parser Text displayNameP = safeDecodeUtf8 <$> displayNameP_ diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index e070732712..4039364726 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -126,6 +126,9 @@ testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \al _ <- accountRows alice "alice, active" 0 alice ##> "/_wallet export 0 0" alice <## "m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727" + -- an index BIP-32 cannot harden is rejected, not wrapped into another account + alice ##> "/_wallet export 4294967296 0" + alice <## "bad chat command: Failed reading: empty" -- any path derives, whether or not a profile holds that account alice ##> "/_wallet export 3 7" l <- getTermLine alice From de895ff43a6ed39a61a89803da5300bc9eb437f5 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 10 Sep 2026 12:21:32 +0000 Subject: [PATCH 10/67] core: /_wallet bind, and name the other profiles instead of numbering them Restoring a chat database older than the key loses which account each profile had, and the keys are still there, so /_wallet bind sets it by hand. The counter moves past an account bound this way, and an account another profile holds is refused. /_wallet now shows the paths of the active profile only, and names the other profiles on the key. Their account indexes were the gap that showed a hidden profile exists. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- bots/src/API/Docs/Commands.hs | 1 + src/Simplex/Chat/Controller.hs | 4 +- src/Simplex/Chat/Library/Commands.hs | 35 +++++----- src/Simplex/Chat/Store/Wallets.hs | 100 ++++++++++++++------------- src/Simplex/Chat/View.hs | 15 ++-- tests/WalletTests.hs | 53 +++++++++----- 6 files changed, 117 insertions(+), 91 deletions(-) diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 0be6b110a7..3673ca7c86 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -451,6 +451,7 @@ undocumentedCommands = "APIVerifyGroupMember", "APIVerifyToken", "APIWallet", + "APIWalletBind", "APIWalletCreate", "APIWalletDelete", "APIWalletExportDerivedSecret", diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d9b71084da..c2aed4e77c 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -418,6 +418,7 @@ data ChatCommand | 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 + | APIWalletBind {accountIndex :: AccountIndex} | APIWalletCreate | APIWalletImport {recoveryPhrase :: Text} | APIWalletExportSeedMnemonic @@ -749,6 +750,7 @@ allowRemoteCommand = \case ExecChatStoreSQL _ -> False ExecAgentStoreSQL _ -> False APIWallet -> False + APIWalletBind {} -> False APIWalletCreate -> False APIWalletImport _ -> False APIWalletExportSeedMnemonic -> False @@ -855,7 +857,7 @@ 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, walletAccounts :: [(Text, AccountIndex, Bool, [(Text, Text)])]} + | CRWallet {user :: User, walletKeyExists :: Bool, walletKeyPaths :: [(Text, Text)], walletProfiles :: [Text]} | CRWalletSeedMnemonic {user :: User, recoveryPhrase :: Text} | CRWalletDerivedSecret {user :: User, keyPath :: Text, address :: Text, derivedSecret :: Text} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index ad21a337e0..25e1350892 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -58,7 +58,7 @@ 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 (deleteSeed, getDeviceSeed, getOrCreateAccountRef, getSeedAccounts, importSeed) +import Simplex.Chat.Store.Wallets (bindAccountIndex, deleteSeed, getAccountIndex, getDeviceSeed, getOrCreateAccountRef, getSeedProfiles, importSeed) import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), accountAddress, accountSecret, deriveNameKey, importRecoveryKey, newSeed, recoveryKeyPhrase, renderNameKeyPath) import Simplex.Chat.Call import Simplex.Chat.Controller @@ -1492,28 +1492,30 @@ processChatCommand cxt nm = \case connId <- withAgent $ \a -> sendServiceReplyAsync a "" (aUserId user) invId (LB.toStrict $ J.encode responseData) pure $ CRServiceReplyAccepted user (AgentConnId connId) APIWallet -> withUser $ \user -> do - seed_ <- withFastStore' getDeviceSeed - accs <- case seed_ of - Nothing -> pure [] + withFastStore' getDeviceSeed >>= \case + Nothing -> pure $ CRWallet user False [] [] Just seed -> do - -- hidden profiles are left out, as they are by /users, but the gap in - -- account indexes still shows that one exists - as <- filter (\(_, _, active, hidden) -> active || not hidden) <$> withFastStore' (\db -> getSeedAccounts db (wsId seed)) - forM as $ \(n, acct, active, _) -> do - keys <- forM [0 .. walletNamesShown - 1] $ \k -> do - acc <- either (throwCmdError . ("wallet: " <>)) pure $ deriveNameKey seed acct k - pure (renderNameKeyPath acct k, tshow (accountAddress acc)) - pure (n, acct, active, keys) - pure $ CRWallet user (isJust seed_) accs + -- other profiles are named but not numbered, so a hidden one leaves no gap + acct_ <- withFastStore' (`getAccountIndex` user) + paths <- forM (maybe [] (\acct -> map ((,) acct) [0 .. walletNamesShown - 1]) acct_) $ \(acct, k) -> do + acc <- either (throwCmdError . ("wallet: " <>)) pure $ deriveNameKey seed acct k + pure (renderNameKeyPath acct k, tshow $ accountAddress acc) + profiles <- withFastStore' $ \db -> getSeedProfiles db (wsId seed) user + pure $ CRWallet user True paths profiles + APIWalletBind acct -> withUser $ \user -> do + seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure + bound <- withFastStore' $ \db -> bindAccountIndex db user (wsId seed) acct + unless bound $ throwCmdError "another profile uses this account" + processChatCommand cxt nm APIWallet APIWalletCreate -> withUser $ \user -> do g <- asks random entropy <- atomically $ newSeed MS256 g - void $ withFastStore' $ \db -> getOrCreateAccountRef db user entropy + withFastStore' $ \db -> getOrCreateAccountRef db user entropy processChatCommand cxt nm APIWallet APIWalletImport phrase -> withUser $ \user -> do entropy <- either (const $ throwCmdError "bad recovery phrase") pure $ importRecoveryKey (encodeUtf8 phrase) - r <- withFastStore' $ \db -> importSeed db user entropy - when (isNothing r) $ throwCmdError "this device already has a wallet key" + imported <- withFastStore' $ \db -> importSeed db user entropy + unless imported $ throwCmdError "this device already has a wallet key" processChatCommand cxt nm APIWallet APIWalletExportSeedMnemonic -> withUser $ \user -> do seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure @@ -5588,6 +5590,7 @@ 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 bind " *> (APIWalletBind <$> keyIndexP), "/_wallet create" $> APIWalletCreate, "/_wallet import " *> (APIWalletImport <$> textP), "/_wallet export " *> (APIWalletExportDerivedSecret <$> keyIndexP <* A.space <*> keyIndexP), diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index b7a41a1e79..69e8348f5b 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -6,22 +6,22 @@ module Simplex.Chat.Store.Wallets ( getDeviceSeed, - getSeedAccounts, + getAccountIndex, + getSeedProfiles, getOrCreateAccountRef, importSeed, + bindAccountIndex, deleteSeed, ) where import Data.ByteString (ByteString) import Data.Int (Int64) -import Data.Maybe (isJust) import Data.Text (Text) import Simplex.Chat.Store.Shared (insertedRowId) import Simplex.Chat.Types (User (..)) -import Simplex.Chat.Wallet (AccountIndex, AccountRef (..), SeedId (..), WalletSeed (..)) +import Simplex.Chat.Wallet (AccountIndex, SeedId (..), WalletSeed (..)) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) -import Simplex.Messaging.Agent.Store.DB (BoolInt (..)) import qualified Simplex.Messaging.Agent.Store.DB as DB #if defined(dbPostgres) @@ -40,65 +40,69 @@ getDeviceSeed db = maybeFirstRow toSeed $ DB.query_ db "SELECT wallet_seed_id, seed FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1" -getWalletSeed :: DB.Connection -> SeedId -> IO (Maybe WalletSeed) -getWalletSeed db (SeedId sId) = - maybeFirstRow toSeed $ - DB.query db "SELECT wallet_seed_id, seed FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) - -getAccountRef :: DB.Connection -> User -> IO (Maybe AccountRef) -getAccountRef db User {userId} = do +getAccountIndex :: DB.Connection -> User -> IO (Maybe AccountIndex) +getAccountIndex db User {userId} = do r <- - maybeFirstRow id $ - DB.query db "SELECT wallet_seed_id, wallet_account_index FROM users WHERE user_id = ?" (Only userId) + maybeFirstRow fromOnly $ + DB.query db "SELECT wallet_account_index FROM users WHERE user_id = ?" (Only userId) pure $ case r of - Just (Just sId, Just ix) -> Just AccountRef {arSeedId = SeedId sId, arIndex = fromIntegral (ix :: Int64)} + Just (Just ix) -> Just $ fromIntegral (ix :: Int64) _ -> Nothing -bindAccount :: DB.Connection -> User -> AccountRef -> IO () -bindAccount db User {userId} AccountRef {arSeedId = SeedId sId, arIndex} = - DB.execute - db - "UPDATE users SET wallet_seed_id = ?, wallet_account_index = ? WHERE user_id = ?" - (sId, fromIntegral arIndex :: Int64, userId) - -getBoundAccount :: DB.Connection -> User -> IO (Maybe (WalletSeed, AccountRef)) -getBoundAccount db user = - getAccountRef db user >>= \case - Nothing -> pure Nothing - Just r -> fmap (\s -> (s, r)) <$> getWalletSeed db (arSeedId r) - -getSeedAccounts :: DB.Connection -> SeedId -> IO [(Text, AccountIndex, Bool, Bool)] -getSeedAccounts db (SeedId sId) = - map toRow +-- | Hidden profiles are left out, as they are by /users. +getSeedProfiles :: DB.Connection -> SeedId -> User -> IO [Text] +getSeedProfiles db (SeedId sId) User {userId} = + map fromOnly <$> DB.query db [sql| - SELECT local_display_name, wallet_account_index, active_user, view_pwd_hash - FROM users WHERE wallet_seed_id = ? ORDER BY wallet_account_index + SELECT local_display_name FROM users + WHERE wallet_seed_id = ? AND user_id != ? AND view_pwd_hash IS NULL + ORDER BY local_display_name |] - (Only sId) - where - toRow (n, ix, BI active, pwdHash) = (n, fromIntegral (ix :: Int64), active, isJust (pwdHash :: Maybe ByteString)) + (sId, userId) -getOrCreateAccountRef :: DB.Connection -> User -> ByteString -> IO (WalletSeed, AccountRef) +bindAccount :: DB.Connection -> User -> SeedId -> AccountIndex -> IO () +bindAccount db User {userId} (SeedId sId) acct = + DB.execute + db + "UPDATE users SET wallet_seed_id = ?, wallet_account_index = ? WHERE user_id = ?" + (sId, fromIntegral acct :: Int64, userId) + +getOrCreateAccountRef :: DB.Connection -> User -> ByteString -> IO () getOrCreateAccountRef db user entropy = - getBoundAccount db user >>= \case - Just bound -> pure bound + getAccountIndex db user >>= \case + Just _ -> pure () Nothing -> getDeviceSeed db >>= maybe (createWalletSeed db entropy) pure >>= bindNewAccount db user --- | Nothing if the device already has a key. -importSeed :: DB.Connection -> User -> ByteString -> IO (Maybe (WalletSeed, AccountRef)) +-- | False if the device already has a key. +importSeed :: DB.Connection -> User -> ByteString -> IO Bool importSeed db user entropy = getDeviceSeed db >>= \case - Just _ -> pure Nothing - Nothing -> Just <$> (createWalletSeed db entropy >>= bindNewAccount db user) + Just _ -> pure False + Nothing -> True <$ (createWalletSeed db entropy >>= bindNewAccount db user) -bindNewAccount :: DB.Connection -> User -> WalletSeed -> IO (WalletSeed, AccountRef) -bindNewAccount db user s = do - ix <- takeAccountIndex db (wsId s) - let r = AccountRef {arSeedId = wsId s, arIndex = ix} - bindAccount db user r - pure (s, r) +-- | False if another profile holds the account. The counter moves past it, so +-- the next profile is not handed the same one. +bindAccountIndex :: DB.Connection -> User -> SeedId -> AccountIndex -> IO Bool +bindAccountIndex db user sId@(SeedId sId') acct = do + taken <- maybeFirstRow fromOnly $ + DB.query + db + "SELECT 1 FROM users WHERE wallet_seed_id = ? AND wallet_account_index = ? AND user_id != ?" + (sId', fromIntegral acct :: Int64, userId user) + case (taken :: Maybe Int64) of + Just _ -> pure False + Nothing -> do + bindAccount db user sId acct + DB.execute + db + "UPDATE wallet_seeds SET next_account_index = ? WHERE wallet_seed_id = ? AND next_account_index <= ?" + (fromIntegral acct + 1 :: Int64, sId', fromIntegral acct :: Int64) + pure True + +bindNewAccount :: DB.Connection -> User -> WalletSeed -> IO () +bindNewAccount db user s = takeAccountIndex db (wsId s) >>= bindAccount db user (wsId s) createWalletSeed :: DB.Connection -> ByteString -> IO WalletSeed createWalletSeed db entropy = do diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 8f598340f1..b6e2b58eb8 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -188,17 +188,14 @@ 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 accs + CRWallet u exists paths profiles | not exists -> ttyUser u ["no wallet key"] - | otherwise -> - ttyUser u $ - concatMap accountRows accs - <> ["this profile has no wallet key" | not (any (\(_, _, active, _) -> active) accs)] + | otherwise -> ttyUser u $ keyRows <> [plain $ "also on this key: " <> T.intercalate ", " profiles | not (null profiles)] where - accountRows (n, acct, active, keys) = - plain ("account " <> tshow acct <> " (" <> n <> (if active then ", active" else "") <> ")") - : zipWith nameRow [0 :: Int ..] keys - nameRow k (path, addr) = plain $ " name " <> tshow k <> " " <> path <> " " <> addr + keyRows + | null paths = ["this profile has no wallet key"] + | otherwise = zipWith nameRow [0 :: Int ..] paths + nameRow k (path, addr) = plain $ "name " <> tshow k <> " " <> path <> " " <> addr CRWalletSeedMnemonic u phrase -> ttyUser u [plain phrase] CRWalletDerivedSecret u path addr secret -> ttyUser u [plain $ path <> " " <> addr <> " " <> secret] CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 4039364726..aaef7c82d6 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -52,11 +52,10 @@ walletTests = do it "imports a phrase, exports it, and refuses a second import" testWalletImport it "exports the secret of any name key" testWalletExportDerivedSecret it "deletes the key, and a key can be imported again" testWalletDelete + it "binds a profile to the account it had" testWalletBind -accountRows :: HasCallStack => TestCC -> String -> Int -> IO [(String, String)] -accountRows cc profile acct = do - cc <## ("account " <> show acct <> " (" <> profile <> ")") - mapM (\_ -> nameRow <$> getTermLine cc) [0 .. 1 :: Int] +nameRows :: HasCallStack => TestCC -> IO [(String, String)] +nameRows cc = mapM (\_ -> nameRow <$> getTermLine cc) [0 .. 1 :: Int] where nameRow l = case words l of ["name", _, path, addr] -> (path, addr) @@ -72,7 +71,7 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet export" alice <## "bad chat command: no wallet key on this device" alice ##> "/_wallet create" - rows <- accountRows alice "alice, active" 0 + rows <- nameRows alice map fst rows `shouldBe` ["m/44'/60'/0'/0/0", "m/44'/60'/0'/0/1"] length (nub $ map snd rows) `shouldBe` 2 @@ -80,38 +79,39 @@ testWalletPersists :: HasCallStack => TestParams -> IO () testWalletPersists ps = do rows <- withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create" - accountRows alice "alice, active" 0 + nameRows alice -- same database, new session: a name bought at that address must stay reachable withTestChat ps "alice" $ \alice -> do alice ##> "/_wallet" - rows' <- accountRows alice "alice, active" 0 + rows' <- nameRows alice rows' `shouldBe` rows testWalletSecondProfile :: HasCallStack => TestParams -> IO () testWalletSecondProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create" - rows <- accountRows alice "alice, active" 0 + rows <- nameRows alice alice ##> "/_wallet export" phrase <- getTermLine alice alice ##> "/create user alisa" showActiveUser alice "alisa" + -- other profiles are named, never numbered alice ##> "/_wallet" - _ <- accountRows alice "alice" 0 alice <## "this profile has no wallet key" + alice <## "also on this key: alice" -- the key belongs to the device, so a profile without an account exports it too alice ##> "/_wallet export" alice <## phrase alice ##> "/_wallet create" - _ <- accountRows alice "alice" 0 - rows' <- accountRows alice "alisa, active" 1 + rows' <- nameRows alice + alice <## "also on this key: alice" + map fst rows' `shouldBe` ["m/44'/60'/1'/0/0", "m/44'/60'/1'/0/1"] null (map snd rows `intersect` map snd rows') `shouldBe` True testWalletImport :: HasCallStack => TestParams -> IO () testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "account 0 (alice, active)" - alice <## " name 0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94" - alice <## " name 1 m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" + alice <## "name 0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + alice <## "name 1 m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" alice ##> "/_wallet export" alice <## B.unpack testPhrase alice ##> ("/_wallet import " <> B.unpack testPhrase) @@ -123,7 +123,7 @@ testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do testWalletExportDerivedSecret :: HasCallStack => TestParams -> IO () testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet import " <> B.unpack testPhrase) - _ <- accountRows alice "alice, active" 0 + _ <- nameRows alice alice ##> "/_wallet export 0 0" alice <## "m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727" -- an index BIP-32 cannot harden is rejected, not wrapped into another account @@ -142,10 +142,29 @@ testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \al testWalletDelete :: HasCallStack => TestParams -> IO () testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet import " <> B.unpack testPhrase) - _ <- accountRows alice "alice, active" 0 + _ <- nameRows alice alice ##> "/_wallet delete" alice <## "no wallet key" -- deleting unbinds the profile, so a key can be imported again alice ##> ("/_wallet import " <> B.unpack testPhrase) - _ <- accountRows alice "alice, active" 0 + _ <- nameRows alice pure () + +-- | Restoring a chat database older than the key rebinds profiles in the order +-- they ask, so the account a profile had is set by hand. +testWalletBind :: HasCallStack => TestParams -> IO () +testWalletBind ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> ("/_wallet import " <> B.unpack testPhrase) + _ <- nameRows alice + alice ##> "/_wallet bind 3" + rows <- nameRows alice + map fst rows `shouldBe` ["m/44'/60'/3'/0/0", "m/44'/60'/3'/0/1"] + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/_wallet bind 3" + alice <## "bad chat command: another profile uses this account" + -- the counter moved past the account bound by hand + alice ##> "/_wallet create" + rows' <- nameRows alice + alice <## "also on this key: alice" + map fst rows' `shouldBe` ["m/44'/60'/4'/0/0", "m/44'/60'/4'/0/1"] From 07acdbb85baa2d3185c24b104055b0ed7aec2047 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 10 Sep 2026 12:31:23 +0000 Subject: [PATCH 11/67] core: say same seed, not same key, for the other profiles Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- src/Simplex/Chat/View.hs | 2 +- tests/WalletTests.hs | 6 +++--- 2 files changed, 4 insertions(+), 4 deletions(-) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index b6e2b58eb8..3fb3357ecd 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -190,7 +190,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te CRServiceReplyAccepted u (AgentConnId cId) -> ttyUser u [plain $ "service reply accepted, connection id: " <> safeDecodeUtf8 (strEncode cId)] CRWallet u exists paths profiles | not exists -> ttyUser u ["no wallet key"] - | otherwise -> ttyUser u $ keyRows <> [plain $ "also on this key: " <> T.intercalate ", " profiles | not (null profiles)] + | otherwise -> ttyUser u $ keyRows <> [plain $ "also on same seed: " <> T.intercalate ", " profiles | not (null profiles)] where keyRows | null paths = ["this profile has no wallet key"] diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index aaef7c82d6..14f7a8f12f 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -97,13 +97,13 @@ testWalletSecondProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> -- other profiles are named, never numbered alice ##> "/_wallet" alice <## "this profile has no wallet key" - alice <## "also on this key: alice" + alice <## "also on same seed: alice" -- the key belongs to the device, so a profile without an account exports it too alice ##> "/_wallet export" alice <## phrase alice ##> "/_wallet create" rows' <- nameRows alice - alice <## "also on this key: alice" + alice <## "also on same seed: alice" map fst rows' `shouldBe` ["m/44'/60'/1'/0/0", "m/44'/60'/1'/0/1"] null (map snd rows `intersect` map snd rows') `shouldBe` True @@ -166,5 +166,5 @@ testWalletBind ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do -- the counter moved past the account bound by hand alice ##> "/_wallet create" rows' <- nameRows alice - alice <## "also on this key: alice" + alice <## "also on same seed: alice" map fst rows' `shouldBe` ["m/44'/60'/4'/0/0", "m/44'/60'/4'/0/1"] From 9cba89aa17a494d0d88408e7bbdfa6d87e429803 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 10 Sep 2026 12:51:09 +0000 Subject: [PATCH 12/67] core: create makes the seed, bind claims a path The two were one command: on a device that already had a seed, /_wallet create only bound a profile. Now create makes the seed and refuses when one exists, and binds every profile that exists, as a new seed has no account that already owns a name. /_wallet import binds nothing. Which account a profile had is what an import is recovering, and the seed does not say, so the profile says it with /_wallet bind . /_wallet bind with no account takes the next free one. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- src/Simplex/Chat/Controller.hs | 2 +- src/Simplex/Chat/Library/Commands.hs | 18 +++--- src/Simplex/Chat/Store/Wallets.hs | 92 +++++++++++++++------------- tests/WalletTests.hs | 24 ++++++-- 4 files changed, 80 insertions(+), 56 deletions(-) diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index c2aed4e77c..179d7be93e 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -418,7 +418,7 @@ data ChatCommand | 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 - | APIWalletBind {accountIndex :: AccountIndex} + | APIWalletBind {boundAccountIndex :: Maybe AccountIndex} | APIWalletCreate | APIWalletImport {recoveryPhrase :: Text} | APIWalletExportSeedMnemonic diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 25e1350892..ac18508ea2 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -58,7 +58,7 @@ 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 (bindAccountIndex, deleteSeed, getAccountIndex, getDeviceSeed, getOrCreateAccountRef, getSeedProfiles, importSeed) +import Simplex.Chat.Store.Wallets (bindAccountIndex, createSeed, deleteSeed, getAccountIndex, getDeviceSeed, getSeedProfiles, importSeed) import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), accountAddress, accountSecret, deriveNameKey, importRecoveryKey, newSeed, recoveryKeyPhrase, renderNameKeyPath) import Simplex.Chat.Call import Simplex.Chat.Controller @@ -1502,19 +1502,20 @@ processChatCommand cxt nm = \case pure (renderNameKeyPath acct k, tshow $ accountAddress acc) profiles <- withFastStore' $ \db -> getSeedProfiles db (wsId seed) user pure $ CRWallet user True paths profiles - APIWalletBind acct -> withUser $ \user -> do + APIWalletBind acct_ -> withUser $ \user -> do seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure - bound <- withFastStore' $ \db -> bindAccountIndex db user (wsId seed) acct + bound <- withFastStore' $ \db -> bindAccountIndex db user (wsId seed) acct_ unless bound $ throwCmdError "another profile uses this account" processChatCommand cxt nm APIWallet - APIWalletCreate -> withUser $ \user -> do + APIWalletCreate -> withUser $ \_ -> do g <- asks random entropy <- atomically $ newSeed MS256 g - withFastStore' $ \db -> getOrCreateAccountRef db user entropy + created <- withFastStore' $ \db -> createSeed db entropy + unless created $ throwCmdError "this device already has a wallet key" processChatCommand cxt nm APIWallet - APIWalletImport phrase -> withUser $ \user -> do + APIWalletImport phrase -> withUser $ \_ -> do entropy <- either (const $ throwCmdError "bad recovery phrase") pure $ importRecoveryKey (encodeUtf8 phrase) - imported <- withFastStore' $ \db -> importSeed db user entropy + imported <- withFastStore' $ \db -> importSeed db entropy unless imported $ throwCmdError "this device already has a wallet key" processChatCommand cxt nm APIWallet APIWalletExportSeedMnemonic -> withUser $ \user -> do @@ -5590,7 +5591,8 @@ 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 bind " *> (APIWalletBind <$> keyIndexP), + "/_wallet bind " *> (APIWalletBind . Just <$> keyIndexP), + "/_wallet bind" $> APIWalletBind Nothing, "/_wallet create" $> APIWalletCreate, "/_wallet import " *> (APIWalletImport <$> textP), "/_wallet export " *> (APIWalletExportDerivedSecret <$> keyIndexP <* A.space <*> keyIndexP), diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index 69e8348f5b..84ab251bba 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -8,13 +8,14 @@ module Simplex.Chat.Store.Wallets ( getDeviceSeed, getAccountIndex, getSeedProfiles, - getOrCreateAccountRef, + createSeed, importSeed, bindAccountIndex, deleteSeed, ) where +import Control.Monad (forM_) import Data.ByteString (ByteString) import Data.Int (Int64) import Data.Text (Text) @@ -62,47 +63,53 @@ getSeedProfiles db (SeedId sId) User {userId} = |] (sId, userId) -bindAccount :: DB.Connection -> User -> SeedId -> AccountIndex -> IO () -bindAccount db User {userId} (SeedId sId) acct = +bindUser :: DB.Connection -> Int64 -> SeedId -> Int64 -> IO () +bindUser db uId (SeedId sId) acct = DB.execute db "UPDATE users SET wallet_seed_id = ?, wallet_account_index = ? WHERE user_id = ?" - (sId, fromIntegral acct :: Int64, userId) + (sId, acct, uId) -getOrCreateAccountRef :: DB.Connection -> User -> ByteString -> IO () -getOrCreateAccountRef db user entropy = - getAccountIndex db user >>= \case - Just _ -> pure () - Nothing -> getDeviceSeed db >>= maybe (createWalletSeed db entropy) pure >>= bindNewAccount db user - --- | False if the device already has a key. -importSeed :: DB.Connection -> User -> ByteString -> IO Bool -importSeed db user entropy = +-- | False if the device already has a key. Every profile is bound, as a new +-- seed has no account that already owns a name. +createSeed :: DB.Connection -> ByteString -> IO Bool +createSeed db entropy = getDeviceSeed db >>= \case - Just _ -> pure False - Nothing -> True <$ (createWalletSeed db entropy >>= bindNewAccount db user) - --- | False if another profile holds the account. The counter moves past it, so --- the next profile is not handed the same one. -bindAccountIndex :: DB.Connection -> User -> SeedId -> AccountIndex -> IO Bool -bindAccountIndex db user sId@(SeedId sId') acct = do - taken <- maybeFirstRow fromOnly $ - DB.query - db - "SELECT 1 FROM users WHERE wallet_seed_id = ? AND wallet_account_index = ? AND user_id != ?" - (sId', fromIntegral acct :: Int64, userId user) - case (taken :: Maybe Int64) of Just _ -> pure False Nothing -> do - bindAccount db user sId acct - DB.execute - db - "UPDATE wallet_seeds SET next_account_index = ? WHERE wallet_seed_id = ? AND next_account_index <= ?" - (fromIntegral acct + 1 :: Int64, sId', fromIntegral acct :: Int64) + s <- createWalletSeed db entropy + uIds <- map fromOnly <$> DB.query_ db "SELECT user_id FROM users ORDER BY user_id" + forM_ (zip uIds [0 ..]) $ \(uId, acct) -> bindUser db uId (wsId s) acct + setNextAccountIndex db (wsId s) (fromIntegral $ length uIds) pure True -bindNewAccount :: DB.Connection -> User -> WalletSeed -> IO () -bindNewAccount db user s = takeAccountIndex db (wsId s) >>= bindAccount db user (wsId s) +-- | False if the device already has a key. No profile is bound: which account +-- a profile had is what the import is recovering, and the seed does not say. +importSeed :: DB.Connection -> ByteString -> IO Bool +importSeed db entropy = + getDeviceSeed db >>= \case + Just _ -> pure False + Nothing -> True <$ createWalletSeed db entropy + +-- | Without an account the next free one is taken. False if another profile +-- holds the account asked for. +bindAccountIndex :: DB.Connection -> User -> SeedId -> Maybe AccountIndex -> IO Bool +bindAccountIndex db User {userId} sId@(SeedId sId') = \case + Nothing -> True <$ (takeAccountIndex db sId >>= bindUser db userId sId) + Just acct -> do + taken <- + maybeFirstRow fromOnly $ + DB.query + db + "SELECT 1 FROM users WHERE wallet_seed_id = ? AND wallet_account_index = ? AND user_id != ?" + (sId', fromIntegral acct :: Int64, userId) + case (taken :: Maybe Int64) of + Just _ -> pure False + Nothing -> do + bindUser db userId sId (fromIntegral acct) + -- the counter moves past it, so the next profile is not handed the same one + setNextAccountIndex db sId (fromIntegral acct + 1) + pure True createWalletSeed :: DB.Connection -> ByteString -> IO WalletSeed createWalletSeed db entropy = do @@ -111,18 +118,21 @@ createWalletSeed db entropy = do pure WalletSeed {wsId = SeedId sId, wsEntropy = entropy} -- | Incremented in SQL, so two profiles cannot be handed the same account. -takeAccountIndex :: DB.Connection -> SeedId -> IO AccountIndex -takeAccountIndex db sId@(SeedId sId') = do - DB.execute db "UPDATE wallet_seeds SET next_account_index = next_account_index + 1 WHERE wallet_seed_id = ?" (Only sId') - subtract 1 <$> getNextAccountIndex db sId - -getNextAccountIndex :: DB.Connection -> SeedId -> IO AccountIndex -getNextAccountIndex db (SeedId sId) = - maybe 0 (fromIntegral :: Int64 -> AccountIndex) +takeAccountIndex :: DB.Connection -> SeedId -> IO Int64 +takeAccountIndex db (SeedId sId) = do + DB.execute db "UPDATE wallet_seeds SET next_account_index = next_account_index + 1 WHERE wallet_seed_id = ?" (Only sId) + maybe 0 (subtract 1) <$> ( maybeFirstRow fromOnly $ DB.query db "SELECT next_account_index FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) ) +setNextAccountIndex :: DB.Connection -> SeedId -> Int64 -> IO () +setNextAccountIndex db (SeedId sId) acct = + DB.execute + db + "UPDATE wallet_seeds SET next_account_index = ? WHERE wallet_seed_id = ? AND next_account_index < ?" + (acct, sId, acct) + -- | Profiles are unbound first, as the foreign key is ON DELETE RESTRICT. deleteSeed :: DB.Connection -> SeedId -> IO () deleteSeed db (SeedId sId) = do diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 14f7a8f12f..a6eabf4b19 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -70,9 +70,13 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice <## "no wallet key" alice ##> "/_wallet export" alice <## "bad chat command: no wallet key on this device" + alice ##> "/create user alisa" + showActiveUser alice "alisa" + -- a new seed has no account that owns a name, so every profile is bound alice ##> "/_wallet create" rows <- nameRows alice - map fst rows `shouldBe` ["m/44'/60'/0'/0/0", "m/44'/60'/0'/0/1"] + alice <## "also on same seed: alice" + map fst rows `shouldBe` ["m/44'/60'/1'/0/0", "m/44'/60'/1'/0/1"] length (nub $ map snd rows) `shouldBe` 2 testWalletPersists :: HasCallStack => TestParams -> IO () @@ -101,7 +105,10 @@ testWalletSecondProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> -- the key belongs to the device, so a profile without an account exports it too alice ##> "/_wallet export" alice <## phrase + -- create is for the seed, and this device has one alice ##> "/_wallet create" + alice <## "bad chat command: this device already has a wallet key" + alice ##> "/_wallet bind" rows' <- nameRows alice alice <## "also on same seed: alice" map fst rows' `shouldBe` ["m/44'/60'/1'/0/0", "m/44'/60'/1'/0/1"] @@ -109,7 +116,10 @@ testWalletSecondProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> testWalletImport :: HasCallStack => TestParams -> IO () testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + -- import binds nothing: which account a profile had is what it is recovering alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice <## "this profile has no wallet key" + alice ##> "/_wallet bind" alice <## "name 0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94" alice <## "name 1 m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" alice ##> "/_wallet export" @@ -122,8 +132,9 @@ testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do testWalletExportDerivedSecret :: HasCallStack => TestParams -> IO () testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + -- the secret of a name key needs no profile bound to that account alice ##> ("/_wallet import " <> B.unpack testPhrase) - _ <- nameRows alice + alice <## "this profile has no wallet key" alice ##> "/_wallet export 0 0" alice <## "m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727" -- an index BIP-32 cannot harden is rejected, not wrapped into another account @@ -142,20 +153,21 @@ testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \al testWalletDelete :: HasCallStack => TestParams -> IO () testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice <## "this profile has no wallet key" + alice ##> "/_wallet bind" _ <- nameRows alice alice ##> "/_wallet delete" alice <## "no wallet key" -- deleting unbinds the profile, so a key can be imported again alice ##> ("/_wallet import " <> B.unpack testPhrase) - _ <- nameRows alice - pure () + alice <## "this profile has no wallet key" -- | Restoring a chat database older than the key rebinds profiles in the order -- they ask, so the account a profile had is set by hand. testWalletBind :: HasCallStack => TestParams -> IO () testWalletBind ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet import " <> B.unpack testPhrase) - _ <- nameRows alice + alice <## "this profile has no wallet key" alice ##> "/_wallet bind 3" rows <- nameRows alice map fst rows `shouldBe` ["m/44'/60'/3'/0/0", "m/44'/60'/3'/0/1"] @@ -164,7 +176,7 @@ testWalletBind ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet bind 3" alice <## "bad chat command: another profile uses this account" -- the counter moved past the account bound by hand - alice ##> "/_wallet create" + alice ##> "/_wallet bind" rows' <- nameRows alice alice <## "also on same seed: alice" map fst rows' `shouldBe` ["m/44'/60'/4'/0/0", "m/44'/60'/4'/0/1"] From c5b44fe848420da6707111125f334b8a1d89e6b6 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 10 Sep 2026 12:54:40 +0000 Subject: [PATCH 13/67] core: the known issue is now about bind, not import Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- .../Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs | 5 ++--- src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql | 5 ++--- 2 files changed, 4 insertions(+), 6 deletions(-) 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 dfacc89385..c8cb3ceea6 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs @@ -11,9 +11,8 @@ m20260908_wallet_seeds = CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes - -- known issue: after import this starts at 0, so a recovered device can - -- re-issue an account that already owns names, and profiles are bound in - -- the order they ask for a key, not the order they had + -- known issue: after an import this starts at 0, so /_wallet bind with no + -- account can hand out one that already owns names next_account_index INTEGER NOT NULL DEFAULT 0, -- one key per device for now single_seed INTEGER NOT NULL DEFAULT 1 diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 04d830fee5..ff0d075675 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -857,9 +857,8 @@ CREATE TABLE rcv_roster_transfers( CREATE TABLE wallet_seeds( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes - -- known issue: after import this starts at 0, so a recovered device can - -- re-issue an account that already owns names, and profiles are bound in - -- the order they ask for a key, not the order they had + -- known issue: after an import this starts at 0, so /_wallet bind with no + -- account can hand out one that already owns names next_account_index INTEGER NOT NULL DEFAULT 0, -- one key per device for now single_seed INTEGER NOT NULL DEFAULT 1 From 1e0c3503e09f1430bf97b85acebfc4a17a06d6d9 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 10 Sep 2026 13:06:45 +0000 Subject: [PATCH 14/67] core: create binds no profile either Create and import differed only in whether they bound a profile, which made the convenient one inconsistent. Neither binds now, so the store keeps one function for both, and a profile reaches an account only through bind. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- src/Simplex/Chat/Library/Commands.hs | 6 +++--- src/Simplex/Chat/Store/Wallets.hs | 28 +++------------------------- tests/WalletTests.hs | 13 ++++++++----- 3 files changed, 14 insertions(+), 33 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index ac18508ea2..29d4585689 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -58,7 +58,7 @@ 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 (bindAccountIndex, createSeed, deleteSeed, getAccountIndex, getDeviceSeed, getSeedProfiles, importSeed) +import Simplex.Chat.Store.Wallets (bindAccountIndex, createSeed, deleteSeed, getAccountIndex, getDeviceSeed, getSeedProfiles) import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), accountAddress, accountSecret, deriveNameKey, importRecoveryKey, newSeed, recoveryKeyPhrase, renderNameKeyPath) import Simplex.Chat.Call import Simplex.Chat.Controller @@ -1515,8 +1515,8 @@ processChatCommand cxt nm = \case processChatCommand cxt nm APIWallet APIWalletImport phrase -> withUser $ \_ -> do entropy <- either (const $ throwCmdError "bad recovery phrase") pure $ importRecoveryKey (encodeUtf8 phrase) - imported <- withFastStore' $ \db -> importSeed db entropy - unless imported $ throwCmdError "this device already has a wallet key" + 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 <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index 84ab251bba..e85363110e 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -9,17 +9,14 @@ module Simplex.Chat.Store.Wallets getAccountIndex, getSeedProfiles, createSeed, - importSeed, bindAccountIndex, deleteSeed, ) where -import Control.Monad (forM_) import Data.ByteString (ByteString) import Data.Int (Int64) import Data.Text (Text) -import Simplex.Chat.Store.Shared (insertedRowId) import Simplex.Chat.Types (User (..)) import Simplex.Chat.Wallet (AccountIndex, SeedId (..), WalletSeed (..)) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) @@ -70,26 +67,13 @@ bindUser db uId (SeedId sId) acct = "UPDATE users SET wallet_seed_id = ?, wallet_account_index = ? WHERE user_id = ?" (sId, acct, uId) --- | False if the device already has a key. Every profile is bound, as a new --- seed has no account that already owns a name. +-- | False if the device already has a key. No profile is bound, as which +-- account a profile takes is said with bind. createSeed :: DB.Connection -> ByteString -> IO Bool createSeed db entropy = getDeviceSeed db >>= \case Just _ -> pure False - Nothing -> do - s <- createWalletSeed db entropy - uIds <- map fromOnly <$> DB.query_ db "SELECT user_id FROM users ORDER BY user_id" - forM_ (zip uIds [0 ..]) $ \(uId, acct) -> bindUser db uId (wsId s) acct - setNextAccountIndex db (wsId s) (fromIntegral $ length uIds) - pure True - --- | False if the device already has a key. No profile is bound: which account --- a profile had is what the import is recovering, and the seed does not say. -importSeed :: DB.Connection -> ByteString -> IO Bool -importSeed db entropy = - getDeviceSeed db >>= \case - Just _ -> pure False - Nothing -> True <$ createWalletSeed db entropy + Nothing -> True <$ DB.execute db "INSERT INTO wallet_seeds (seed) VALUES (?)" (Only entropy) -- | Without an account the next free one is taken. False if another profile -- holds the account asked for. @@ -111,12 +95,6 @@ bindAccountIndex db User {userId} sId@(SeedId sId') = \case setNextAccountIndex db sId (fromIntegral acct + 1) pure True -createWalletSeed :: DB.Connection -> ByteString -> IO WalletSeed -createWalletSeed db entropy = do - DB.execute db "INSERT INTO wallet_seeds (seed) VALUES (?)" (Only entropy) - sId <- insertedRowId db - pure WalletSeed {wsId = SeedId sId, wsEntropy = entropy} - -- | Incremented in SQL, so two profiles cannot be handed the same account. takeAccountIndex :: DB.Connection -> SeedId -> IO Int64 takeAccountIndex db (SeedId sId) = do diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index a6eabf4b19..6aed5410a6 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -70,19 +70,20 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice <## "no wallet key" alice ##> "/_wallet export" alice <## "bad chat command: no wallet key on this device" - alice ##> "/create user alisa" - showActiveUser alice "alisa" - -- a new seed has no account that owns a name, so every profile is bound + -- creating the seed binds no profile to an account alice ##> "/_wallet create" + alice <## "this profile has no wallet key" + alice ##> "/_wallet bind" rows <- nameRows alice - alice <## "also on same seed: alice" - map fst rows `shouldBe` ["m/44'/60'/1'/0/0", "m/44'/60'/1'/0/1"] + map fst rows `shouldBe` ["m/44'/60'/0'/0/0", "m/44'/60'/0'/0/1"] length (nub $ map snd rows) `shouldBe` 2 testWalletPersists :: HasCallStack => TestParams -> IO () testWalletPersists ps = do rows <- withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create" + alice <## "this profile has no wallet key" + alice ##> "/_wallet bind" nameRows alice -- same database, new session: a name bought at that address must stay reachable withTestChat ps "alice" $ \alice -> do @@ -93,6 +94,8 @@ testWalletPersists ps = do testWalletSecondProfile :: HasCallStack => TestParams -> IO () testWalletSecondProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create" + alice <## "this profile has no wallet key" + alice ##> "/_wallet bind" rows <- nameRows alice alice ##> "/_wallet export" phrase <- getTermLine alice From 2d9fe10252e5ab5220f8bd679008ecee6301cf7f Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 10 Sep 2026 13:12:27 +0000 Subject: [PATCH 15/67] core: test the restore cases the key has to survive A database backed up after the key carries the key and the binding, so the restore is all of it and an import is refused. A database backed up before the key comes back with the profiles and no binding, so the phrase is imported and each profile says which account was its. A key imported before the database is restored is replaced by what the backup held. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- tests/WalletTests.hs | 66 ++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 66 insertions(+) diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 6aed5410a6..ac31d56738 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -53,6 +53,9 @@ walletTests = do it "exports the secret of any name key" testWalletExportDerivedSecret it "deletes the key, and a key can be imported again" testWalletDelete it "binds a profile to the account it had" testWalletBind + it "needs no import when the database was backed up after the key" testWalletBackupAfterKey + it "rebinds by index when the database was backed up before the key" testWalletBackupBeforeKey + it "discards a key imported before the database is restored" testWalletImportThenRestore nameRows :: HasCallStack => TestCC -> IO [(String, String)] nameRows cc = mapM (\_ -> nameRow <$> getTermLine cc) [0 .. 1 :: Int] @@ -183,3 +186,66 @@ testWalletBind ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do rows' <- nameRows alice alice <## "also on same seed: alice" map fst rows' `shouldBe` ["m/44'/60'/4'/0/0", "m/44'/60'/4'/0/1"] + +-- | The state a chat database backed up before the key restores to: the +-- profiles are the same rows, and nothing knows about a key. +forgetKey :: HasCallStack => TestCC -> IO () +forgetKey cc = do + cc ##> "/sql chat UPDATE users SET wallet_seed_id = NULL, wallet_account_index = NULL" + cc ##> "/sql chat DELETE FROM wallet_seeds" + +testWalletBackupAfterKey :: HasCallStack => TestParams -> IO () +testWalletBackupAfterKey ps = do + rows <- withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> "/_wallet create" + alice <## "this profile has no wallet key" + alice ##> "/_wallet bind" + nameRows alice + -- the key and the binding are both in the database, so the restore is all of it + withTestChat ps "alice" $ \alice -> do + alice ##> "/_wallet" + rows' <- nameRows alice + rows' `shouldBe` rows + alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice <## "bad chat command: this device already has a wallet key" + +testWalletBackupBeforeKey :: HasCallStack => TestParams -> IO () +testWalletBackupBeforeKey ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice <## "this profile has no wallet key" + alice ##> "/_wallet bind 1" + aliceRows <- nameRows alice + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/_wallet bind 0" + alisaRows <- nameRows alice + alice <## "also on same seed: alice" + forgetKey alice + alice ##> "/_wallet" + alice <## "no wallet key" + -- the phrase alone puts every account back, and each profile says which was its + alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice <## "this profile has no wallet key" + alice ##> "/_wallet bind 0" + alisaRows' <- nameRows alice + alisaRows' `shouldBe` alisaRows + alice ##> "/user alice" + showActiveUser alice "alice (Alice)" + alice ##> "/_wallet bind 1" + aliceRows' <- nameRows alice + alice <## "also on same seed: alisa" + aliceRows' `shouldBe` aliceRows + +testWalletImportThenRestore :: HasCallStack => TestParams -> IO () +testWalletImportThenRestore ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice <## "this profile has no wallet key" + -- restoring the database replaces the key with what the backup held, which is nothing + forgetKey alice + alice ##> "/_wallet" + alice <## "no wallet key" + alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice <## "this profile has no wallet key" + alice ##> "/_wallet bind" + rows <- nameRows alice + map fst rows `shouldBe` ["m/44'/60'/0'/0/0", "m/44'/60'/0'/0/1"] From 03b6ed9a53569f95b923496f992664442f105ae1 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 10 Sep 2026 13:45:48 +0000 Subject: [PATCH 16/67] core: drop the types nothing reads, and say account where it means account AccountRef and WalletAccount were built and never read: deriveNameKey now returns the key, and the address comes from addressFromPrivateKey, which already exists. SeedId is a plain Int64 like every other row id here. The master key is derived once per command rather than once per name key, which is where PBKDF2 runs. A device has a key and a profile has an account, so /_wallet says "no account for this profile" rather than repeating "no wallet key", which is what it says when the device has none. Also: no "wallet: " in front of "bad chat command: ", strEncode for the address, the repeated seed lookup in one place, comments cut to what the code does not say, and the export test pins its addresses and secrets, including one whose first byte is zero. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- src/Simplex/Chat/Library/Commands.hs | 36 ++++++----- .../Migrations/M20260908_wallet_seeds.hs | 2 +- src/Simplex/Chat/Store/Wallets.hs | 20 +++--- src/Simplex/Chat/View.hs | 2 +- src/Simplex/Chat/Wallet.hs | 56 ++++++---------- tests/WalletTests.hs | 64 +++++++++---------- 6 files changed, 80 insertions(+), 100 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 29d4585689..d5c6178ff6 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -59,7 +59,8 @@ 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 (bindAccountIndex, createSeed, deleteSeed, getAccountIndex, getDeviceSeed, getSeedProfiles) -import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), accountAddress, accountSecret, deriveNameKey, importRecoveryKey, newSeed, recoveryKeyPhrase, renderNameKeyPath) +import Simplex.Chat.Wallet (AccountIndex, WalletSeed (..), deriveNameKey, importRecoveryKey, nameKeySecret, newSeed, recoveryKeyPhrase, renderNameKeyPath, seedMaster) +import Simplex.Messaging.Eth.Address (addressFromPrivateKey) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -1495,15 +1496,13 @@ processChatCommand cxt nm = \case withFastStore' getDeviceSeed >>= \case Nothing -> pure $ CRWallet user False [] [] Just seed -> do - -- other profiles are named but not numbered, so a hidden one leaves no gap acct_ <- withFastStore' (`getAccountIndex` user) - paths <- forM (maybe [] (\acct -> map ((,) acct) [0 .. walletNamesShown - 1]) acct_) $ \(acct, k) -> do - acc <- either (throwCmdError . ("wallet: " <>)) pure $ deriveNameKey seed acct k - pure (renderNameKeyPath acct k, tshow $ accountAddress acc) + paths <- maybe (pure []) (nameKeyRows seed) acct_ + -- other profiles are named but not numbered, so a hidden one leaves no gap profiles <- withFastStore' $ \db -> getSeedProfiles db (wsId seed) user pure $ CRWallet user True paths profiles APIWalletBind acct_ -> withUser $ \user -> do - seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure + seed <- deviceSeed bound <- withFastStore' $ \db -> bindAccountIndex db user (wsId seed) acct_ unless bound $ throwCmdError "another profile uses this account" processChatCommand cxt nm APIWallet @@ -1519,15 +1518,15 @@ processChatCommand cxt nm = \case unless created $ throwCmdError "this device already has a wallet key" processChatCommand cxt nm APIWallet APIWalletExportSeedMnemonic -> withUser $ \user -> do - seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure - phrase <- either (throwCmdError . ("wallet: " <>)) pure $ recoveryKeyPhrase seed + seed <- deviceSeed + phrase <- either throwCmdError pure $ recoveryKeyPhrase seed pure $ CRWalletSeedMnemonic user (safeDecodeUtf8 phrase) APIWalletExportDerivedSecret acct nameIdx -> withUser $ \user -> do - seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure - acc <- either (throwCmdError . ("wallet: " <>)) pure $ deriveNameKey seed acct nameIdx - pure $ CRWalletDerivedSecret user (renderNameKeyPath acct nameIdx) (tshow $ accountAddress acc) (safeDecodeUtf8 $ accountSecret acc) + seed <- deviceSeed + k <- either throwCmdError pure $ seedMaster seed >>= \m -> deriveNameKey m acct nameIdx + pure $ CRWalletDerivedSecret user (renderNameKeyPath acct nameIdx) (decodeLatin1 . strEncode $ addressFromPrivateKey k) (safeDecodeUtf8 $ nameKeySecret k) APIWalletDelete -> withUser $ \_ -> do - seed <- withFastStore' getDeviceSeed >>= maybe (throwCmdError noKeyError) pure + seed <- deviceSeed withFastStore' $ \db -> deleteSeed db (wsId seed) processChatCommand cxt nm APIWallet APISendCallInvitation contactId callType -> withUser $ \user -> do @@ -5466,12 +5465,17 @@ withExpirationDate globalTTL chatItemTTL action = do let ttl = fromMaybe globalTTL chatItemTTL when (ttl > 0) $ action $ addUTCTime (-1 * fromIntegral ttl) currentTs --- | Name keys shown per profile, to check derivation against other wallets. -walletNamesShown :: NameIndex +walletNamesShown :: Int walletNamesShown = 2 -noKeyError :: String -noKeyError = "no wallet key on this device" +deviceSeed :: CM WalletSeed +deviceSeed = withFastStore' getDeviceSeed >>= maybe (throwCmdError "no wallet key on this device") pure + +nameKeyRows :: WalletSeed -> AccountIndex -> CM [(Text, Text)] +nameKeyRows seed acct = either throwCmdError pure $ do + master <- seedMaster seed + forM (take walletNamesShown [0 ..]) $ \nm -> + (renderNameKeyPath acct nm,) . decodeLatin1 . strEncode . addressFromPrivateKey <$> deriveNameKey master acct nm chatCommandP :: Parser ChatCommand chatCommandP = 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 c8cb3ceea6..a1cbbe0e91 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs @@ -10,7 +10,7 @@ m20260908_wallet_seeds = [sql| CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, - seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes + seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes -- known issue: after an import this starts at 0, so /_wallet bind with no -- account can hand out one that already owns names next_account_index INTEGER NOT NULL DEFAULT 0, diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index e85363110e..6934921504 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -18,7 +18,7 @@ import Data.ByteString (ByteString) import Data.Int (Int64) import Data.Text (Text) import Simplex.Chat.Types (User (..)) -import Simplex.Chat.Wallet (AccountIndex, SeedId (..), WalletSeed (..)) +import Simplex.Chat.Wallet (AccountIndex, SeedId, WalletSeed (..)) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -31,7 +31,7 @@ import Database.SQLite.Simple.QQ (sql) #endif toSeed :: (Int64, ByteString) -> WalletSeed -toSeed (sId, seed) = WalletSeed {wsId = SeedId sId, wsEntropy = seed} +toSeed (sId, seed) = WalletSeed {wsId = sId, wsEntropy = seed} getDeviceSeed :: DB.Connection -> IO (Maybe WalletSeed) getDeviceSeed db = @@ -49,7 +49,7 @@ getAccountIndex db User {userId} = do -- | Hidden profiles are left out, as they are by /users. getSeedProfiles :: DB.Connection -> SeedId -> User -> IO [Text] -getSeedProfiles db (SeedId sId) User {userId} = +getSeedProfiles db sId User {userId} = map fromOnly <$> DB.query db @@ -61,7 +61,7 @@ getSeedProfiles db (SeedId sId) User {userId} = (sId, userId) bindUser :: DB.Connection -> Int64 -> SeedId -> Int64 -> IO () -bindUser db uId (SeedId sId) acct = +bindUser db uId sId acct = DB.execute db "UPDATE users SET wallet_seed_id = ?, wallet_account_index = ? WHERE user_id = ?" @@ -78,7 +78,7 @@ createSeed db entropy = -- | Without an account the next free one is taken. False if another profile -- holds the account asked for. bindAccountIndex :: DB.Connection -> User -> SeedId -> Maybe AccountIndex -> IO Bool -bindAccountIndex db User {userId} sId@(SeedId sId') = \case +bindAccountIndex db User {userId} sId = \case Nothing -> True <$ (takeAccountIndex db sId >>= bindUser db userId sId) Just acct -> do taken <- @@ -86,7 +86,7 @@ bindAccountIndex db User {userId} sId@(SeedId sId') = \case DB.query db "SELECT 1 FROM users WHERE wallet_seed_id = ? AND wallet_account_index = ? AND user_id != ?" - (sId', fromIntegral acct :: Int64, userId) + (sId, fromIntegral acct :: Int64, userId) case (taken :: Maybe Int64) of Just _ -> pure False Nothing -> do @@ -95,9 +95,9 @@ bindAccountIndex db User {userId} sId@(SeedId sId') = \case setNextAccountIndex db sId (fromIntegral acct + 1) pure True --- | Incremented in SQL, so two profiles cannot be handed the same account. +-- | So two profiles cannot be handed the same account. takeAccountIndex :: DB.Connection -> SeedId -> IO Int64 -takeAccountIndex db (SeedId sId) = do +takeAccountIndex db sId = do DB.execute db "UPDATE wallet_seeds SET next_account_index = next_account_index + 1 WHERE wallet_seed_id = ?" (Only sId) maybe 0 (subtract 1) <$> ( maybeFirstRow fromOnly $ @@ -105,7 +105,7 @@ takeAccountIndex db (SeedId sId) = do ) setNextAccountIndex :: DB.Connection -> SeedId -> Int64 -> IO () -setNextAccountIndex db (SeedId sId) acct = +setNextAccountIndex db sId acct = DB.execute db "UPDATE wallet_seeds SET next_account_index = ? WHERE wallet_seed_id = ? AND next_account_index < ?" @@ -113,6 +113,6 @@ setNextAccountIndex db (SeedId sId) acct = -- | Profiles are unbound first, as the foreign key is ON DELETE RESTRICT. deleteSeed :: DB.Connection -> SeedId -> IO () -deleteSeed db (SeedId sId) = do +deleteSeed db sId = do DB.execute db "UPDATE users SET wallet_seed_id = NULL, wallet_account_index = NULL WHERE wallet_seed_id = ?" (Only sId) DB.execute db "DELETE FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 3fb3357ecd..592f20de31 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -193,7 +193,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te | otherwise -> ttyUser u $ keyRows <> [plain $ "also on same seed: " <> T.intercalate ", " profiles | not (null profiles)] where keyRows - | null paths = ["this profile has no wallet key"] + | null paths = ["no account for this profile"] | otherwise = zipWith nameRow [0 :: Int ..] paths nameRow k (path, addr) = plain $ "name " <> tshow k <> " " <> path <> " " <> addr CRWalletSeedMnemonic u phrase -> ttyUser u [plain phrase] diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index b7c78bd0d0..a5cfa61ce3 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -2,22 +2,20 @@ -- | BIP-39 seeds and the keys derived from them. -- --- Two layers: one account path per profile, and one key per name under it. A --- per-profile key would hand over every name that profile owns. +-- One account path per profile, one key per name under it. A name's secret is +-- a leaf, so exporting it hands over that name only. module Simplex.Chat.Wallet - ( SeedId (..), + ( SeedId, WalletSeed (..), AccountIndex, NameIndex, - AccountRef (..), - WalletAccount, newSeed, importRecoveryKey, recoveryKeyPhrase, + seedMaster, deriveNameKey, renderNameKeyPath, - accountAddress, - accountSecret, + nameKeySecret, ) where @@ -32,10 +30,8 @@ import Data.Word (Word32) 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 (Address, addressFromPrivateKey) -newtype SeedId = SeedId Int64 - deriving (Eq, Ord, Show) +type SeedId = Int64 -- | BIP-44 account index, one per chat profile. type AccountIndex = Word32 @@ -52,21 +48,6 @@ data WalletSeed = WalletSeed instance Show WalletSeed where show s = "WalletSeed " <> show (wsId s) <> " " -data AccountRef = AccountRef - { arSeedId :: SeedId, - arIndex :: AccountIndex - } - deriving (Eq, Show) - -data WalletAccount = WalletAccount - { waRef :: AccountRef, - waKey :: S.PrivateKey - } - deriving (Eq) - -instance Show WalletAccount where - show a = "WalletAccount " <> show (waRef a) <> " " - -- | 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 @@ -77,23 +58,22 @@ importRecoveryKey phrase = B39.mnemonicToEntropy <$> B39.parseMnemonic phrase recoveryKeyPhrase :: WalletSeed -> Either String ByteString recoveryKeyPhrase s = B39.mnemonicPhrase <$> B39.entropyToMnemonic (wsEntropy s) --- | Standard BIP-44, so the phrase reaches the same addresses in other wallets. +-- | Deriving this runs PBKDF2, so it is done once per command. +seedMaster :: WalletSeed -> Either String B32.ExtendedKey +seedMaster s = do + m <- B39.entropyToMnemonic (wsEntropy s) + B32.masterKey (B39.mnemonicToSeed m "") + +-- | So the phrase reaches the same addresses in other wallets. nameKeyPath :: AccountIndex -> NameIndex -> [Word32] nameKeyPath acc nm = [B32.hardened 44, B32.hardened 60, B32.hardened acc, 0, nm] renderNameKeyPath :: AccountIndex -> NameIndex -> Text renderNameKeyPath acc nm = decodeLatin1 . B32.renderPath $ nameKeyPath acc nm -deriveNameKey :: WalletSeed -> AccountIndex -> NameIndex -> Either String WalletAccount -deriveNameKey s acc nm = do - m <- B39.entropyToMnemonic (wsEntropy s) - master <- B32.masterKey (B39.mnemonicToSeed m "") - xk <- B32.derivePath master (nameKeyPath acc nm) - pure WalletAccount {waRef = AccountRef {arSeedId = wsId s, arIndex = acc}, waKey = B32.xkKey xk} +deriveNameKey :: B32.ExtendedKey -> AccountIndex -> NameIndex -> Either String S.PrivateKey +deriveNameKey master acc nm = B32.xkKey <$> B32.derivePath master (nameKeyPath acc nm) -accountAddress :: WalletAccount -> Address -accountAddress = addressFromPrivateKey . waKey - --- | Hex, as wallets take it when a key is imported on its own. -accountSecret :: WalletAccount -> ByteString -accountSecret a = "0x" <> BAE.convertToBase BAE.Base16 (S.unPrivateKey $ waKey a) +-- | 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) diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index ac31d56738..9434d9ca63 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -10,7 +10,9 @@ import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Either (isLeft) import Data.List (intersect, nub) -import Simplex.Chat.Wallet (SeedId (..), WalletSeed (..), accountAddress, accountSecret, deriveNameKey, importRecoveryKey, recoveryKeyPhrase, renderNameKeyPath) +import Simplex.Chat.Wallet (AccountIndex, NameIndex, WalletSeed (..), deriveNameKey, importRecoveryKey, nameKeySecret, recoveryKeyPhrase, renderNameKeyPath, seedMaster) +import qualified Simplex.Messaging.Crypto.Secp256k1 as S +import Simplex.Messaging.Eth.Address (addressFromPrivateKey) import Test.Hspec hiding (it) import qualified Test.Hspec as Hspec @@ -19,14 +21,15 @@ testPhrase :: ByteString testPhrase = "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about" testSeed :: WalletSeed -testSeed = WalletSeed {wsId = SeedId 1, wsEntropy = either error id $ importRecoveryKey testPhrase} +testSeed = WalletSeed {wsId = 1, wsEntropy = either error id $ importRecoveryKey testPhrase} + +nameKey :: AccountIndex -> NameIndex -> Either String S.PrivateKey +nameKey acc nm = seedMaster testSeed >>= \m -> deriveNameKey m acc nm walletDerivationTests :: Spec walletDerivationTests = do - -- profile 0's names are MetaMask's account list, and each profile's first - -- name is the matching Ledger Live account Hspec.it "name keys line up with other wallets' derivation" $ do - let addrOf i k = either error (show . accountAddress) (deriveNameKey testSeed i k) + let addrOf i k = either error (show . addressFromPrivateKey) (nameKey i k) -- MetaMask accounts 1 and 2 for this phrase addrOf 0 0 `shouldBe` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" addrOf 0 1 `shouldBe` "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" @@ -34,8 +37,8 @@ walletDerivationTests = do addrOf 1 0 `shouldBe` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265" Hspec.it "derives the same secret as other wallets" $ -- MetaMask account 1 for this phrase, as exported by "Show private key" - either error (show . accountSecret) (deriveNameKey testSeed 0 0) - `shouldBe` "\"0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727\"" + either error nameKeySecret (nameKey 0 0) + `shouldBe` "0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727" Hspec.it "renders the path a name key sits at" $ do renderNameKeyPath 0 0 `shouldBe` "m/44'/60'/0'/0/0" renderNameKeyPath 2 7 `shouldBe` "m/44'/60'/2'/0/7" @@ -73,9 +76,8 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice <## "no wallet key" alice ##> "/_wallet export" alice <## "bad chat command: no wallet key on this device" - -- creating the seed binds no profile to an account alice ##> "/_wallet create" - alice <## "this profile has no wallet key" + alice <## "no account for this profile" alice ##> "/_wallet bind" rows <- nameRows alice map fst rows `shouldBe` ["m/44'/60'/0'/0/0", "m/44'/60'/0'/0/1"] @@ -85,7 +87,7 @@ testWalletPersists :: HasCallStack => TestParams -> IO () testWalletPersists ps = do rows <- withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create" - alice <## "this profile has no wallet key" + alice <## "no account for this profile" alice ##> "/_wallet bind" nameRows alice -- same database, new session: a name bought at that address must stay reachable @@ -97,7 +99,7 @@ testWalletPersists ps = do testWalletSecondProfile :: HasCallStack => TestParams -> IO () testWalletSecondProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create" - alice <## "this profile has no wallet key" + alice <## "no account for this profile" alice ##> "/_wallet bind" rows <- nameRows alice alice ##> "/_wallet export" @@ -106,12 +108,11 @@ testWalletSecondProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> showActiveUser alice "alisa" -- other profiles are named, never numbered alice ##> "/_wallet" - alice <## "this profile has no wallet key" + alice <## "no account for this profile" alice <## "also on same seed: alice" -- the key belongs to the device, so a profile without an account exports it too alice ##> "/_wallet export" alice <## phrase - -- create is for the seed, and this device has one alice ##> "/_wallet create" alice <## "bad chat command: this device already has a wallet key" alice ##> "/_wallet bind" @@ -124,7 +125,7 @@ testWalletImport :: HasCallStack => TestParams -> IO () testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do -- import binds nothing: which account a profile had is what it is recovering alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "this profile has no wallet key" + alice <## "no account for this profile" alice ##> "/_wallet bind" alice <## "name 0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94" alice <## "name 1 m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" @@ -140,7 +141,7 @@ testWalletExportDerivedSecret :: HasCallStack => TestParams -> IO () testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do -- the secret of a name key needs no profile bound to that account alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "this profile has no wallet key" + alice <## "no account for this profile" alice ##> "/_wallet export 0 0" alice <## "m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727" -- an index BIP-32 cannot harden is rejected, not wrapped into another account @@ -148,32 +149,28 @@ testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \al alice <## "bad chat command: Failed reading: empty" -- any path derives, whether or not a profile holds that account alice ##> "/_wallet export 3 7" - l <- getTermLine alice - case words l of - [path, addr, secret] -> do - path `shouldBe` "m/44'/60'/3'/0/7" - length addr `shouldBe` 42 - length secret `shouldBe` 66 - _ -> error $ "unexpected export row: " <> l + alice <## "m/44'/60'/3'/0/7 0xb8cb8628d242fF621adb05E75b7bF16c9b496740 0x5fa3f03c127d150c82f54291f9989c955c3857a54df7abbb50e28199a0bbaac1" + -- a secret whose first byte is zero keeps its 64 hex digits + alice ##> "/_wallet export 0 15" + alice <## "m/44'/60'/0'/0/15 0xa25d37554EB084969C85362f7E6B1A6108e51d0e 0x009a1ccd9c667416d9db6246a35d022b1799517c0cd8547bb07ce280c119ae3c" testWalletDelete :: HasCallStack => TestParams -> IO () testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "this profile has no wallet key" + alice <## "no account for this profile" alice ##> "/_wallet bind" _ <- nameRows alice alice ##> "/_wallet delete" alice <## "no wallet key" -- deleting unbinds the profile, so a key can be imported again alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "this profile has no wallet key" + alice <## "no account for this profile" --- | Restoring a chat database older than the key rebinds profiles in the order --- they ask, so the account a profile had is set by hand. +-- | A profile says which account was its, as nothing else knows. testWalletBind :: HasCallStack => TestParams -> IO () testWalletBind ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "this profile has no wallet key" + alice <## "no account for this profile" alice ##> "/_wallet bind 3" rows <- nameRows alice map fst rows `shouldBe` ["m/44'/60'/3'/0/0", "m/44'/60'/3'/0/1"] @@ -187,8 +184,7 @@ testWalletBind ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice <## "also on same seed: alice" map fst rows' `shouldBe` ["m/44'/60'/4'/0/0", "m/44'/60'/4'/0/1"] --- | The state a chat database backed up before the key restores to: the --- profiles are the same rows, and nothing knows about a key. +-- | The state a chat database backed up before the key restores to. forgetKey :: HasCallStack => TestCC -> IO () forgetKey cc = do cc ##> "/sql chat UPDATE users SET wallet_seed_id = NULL, wallet_account_index = NULL" @@ -198,7 +194,7 @@ testWalletBackupAfterKey :: HasCallStack => TestParams -> IO () testWalletBackupAfterKey ps = do rows <- withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create" - alice <## "this profile has no wallet key" + alice <## "no account for this profile" alice ##> "/_wallet bind" nameRows alice -- the key and the binding are both in the database, so the restore is all of it @@ -212,7 +208,7 @@ testWalletBackupAfterKey ps = do testWalletBackupBeforeKey :: HasCallStack => TestParams -> IO () testWalletBackupBeforeKey ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "this profile has no wallet key" + alice <## "no account for this profile" alice ##> "/_wallet bind 1" aliceRows <- nameRows alice alice ##> "/create user alisa" @@ -225,7 +221,7 @@ testWalletBackupBeforeKey ps = withNewTestChat ps "alice" aliceProfile $ \alice alice <## "no wallet key" -- the phrase alone puts every account back, and each profile says which was its alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "this profile has no wallet key" + alice <## "no account for this profile" alice ##> "/_wallet bind 0" alisaRows' <- nameRows alice alisaRows' `shouldBe` alisaRows @@ -239,13 +235,13 @@ testWalletBackupBeforeKey ps = withNewTestChat ps "alice" aliceProfile $ \alice testWalletImportThenRestore :: HasCallStack => TestParams -> IO () testWalletImportThenRestore ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "this profile has no wallet key" + alice <## "no account for this profile" -- restoring the database replaces the key with what the backup held, which is nothing forgetKey alice alice ##> "/_wallet" alice <## "no wallet key" alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "this profile has no wallet key" + alice <## "no account for this profile" alice ##> "/_wallet bind" rows <- nameRows alice map fst rows `shouldBe` ["m/44'/60'/0'/0/0", "m/44'/60'/0'/0/1"] From 619434626c435bbbc93344892845833b567e4e7c Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 10 Sep 2026 14:28:06 +0000 Subject: [PATCH 17/67] core: store the seed as bytes, and bind one account per profile On Postgres a bare ByteString binds as a text literal, so entropy with a backslash or a high bit was rejected and entropy with a zero byte was truncated: the BIP-39 test vector stored as an empty seed. Blob columns go through DB.Binary here, as every other one does. /_wallet bind with no account moved a profile that already had one to a fresh account, abandoning the old one without saying so. It refuses now; moving is asked for by number. The counter could walk past 2^31, where BIP-32 hardening folds every index back onto a low one, so a profile handed 2147483648 derived account 0's keys and printed account 0's path. Binding refuses once the counter is there, and a very long index is now rejected on its digit count rather than after reading it. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- src/Simplex/Chat/Library/Commands.hs | 11 ++++++----- src/Simplex/Chat/Store/Wallets.hs | 24 ++++++++++++++++-------- tests/WalletTests.hs | 22 ++++++++++++++++++++++ 3 files changed, 44 insertions(+), 13 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index d5c6178ff6..2941802e47 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1503,8 +1503,7 @@ processChatCommand cxt nm = \case pure $ CRWallet user True paths profiles APIWalletBind acct_ -> withUser $ \user -> do seed <- deviceSeed - bound <- withFastStore' $ \db -> bindAccountIndex db user (wsId seed) acct_ - unless bound $ throwCmdError "another profile uses this account" + withFastStore' (\db -> bindAccountIndex db user (wsId seed) acct_) >>= either throwCmdError pure processChatCommand cxt nm APIWallet APIWalletCreate -> withUser $ \_ -> do g <- asks random @@ -6143,10 +6142,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 + -- 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 - i <- A.decimal :: Parser Integer - if i < 0x80000000 then pure (fromIntegral i) else fail "key index too large" + 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" displayNameP :: Parser Text displayNameP = safeDecodeUtf8 <$> displayNameP_ diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index 6934921504..99eb4b226e 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -73,13 +73,21 @@ createSeed :: DB.Connection -> ByteString -> IO Bool createSeed db entropy = getDeviceSeed db >>= \case Just _ -> pure False - Nothing -> True <$ DB.execute db "INSERT INTO wallet_seeds (seed) VALUES (?)" (Only entropy) + Nothing -> True <$ DB.execute db "INSERT INTO wallet_seeds (seed) VALUES (?)" (Only $ DB.Binary entropy) --- | Without an account the next free one is taken. False if another profile --- holds the account asked for. -bindAccountIndex :: DB.Connection -> User -> SeedId -> Maybe AccountIndex -> IO Bool -bindAccountIndex db User {userId} sId = \case - Nothing -> True <$ (takeAccountIndex db sId >>= bindUser db userId sId) +-- | Without an account the next free one is taken, which a profile that has +-- one does not need: moving to another account is asked for by number. +bindAccountIndex :: DB.Connection -> User -> SeedId -> Maybe AccountIndex -> IO (Either String ()) +bindAccountIndex db user@User {userId} sId = \case + Nothing -> + getAccountIndex db user >>= \case + Just _ -> pure $ Left "this profile already has an account" + Nothing -> do + acct <- takeAccountIndex db sId + -- BIP-32 hardens at 2^31, and every index above it is the same key again + if acct >= 0x80000000 + then pure $ Left "no free account on this key" + else Right () <$ bindUser db userId sId acct Just acct -> do taken <- maybeFirstRow fromOnly $ @@ -88,12 +96,12 @@ bindAccountIndex db User {userId} sId = \case "SELECT 1 FROM users WHERE wallet_seed_id = ? AND wallet_account_index = ? AND user_id != ?" (sId, fromIntegral acct :: Int64, userId) case (taken :: Maybe Int64) of - Just _ -> pure False + Just _ -> pure $ Left "another profile uses this account" Nothing -> do bindUser db userId sId (fromIntegral acct) -- the counter moves past it, so the next profile is not handed the same one setNextAccountIndex db sId (fromIntegral acct + 1) - pure True + pure $ Right () -- | So two profiles cannot be handed the same account. takeAccountIndex :: DB.Connection -> SeedId -> IO Int64 diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 9434d9ca63..cd509e5473 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -56,6 +56,7 @@ walletTests = do it "exports the secret of any name key" testWalletExportDerivedSecret it "deletes the key, and a key can be imported again" testWalletDelete it "binds a profile to the account it had" testWalletBind + it "binds a profile once, and only to an account BIP-32 can harden" testWalletBindLimits it "needs no import when the database was backed up after the key" testWalletBackupAfterKey it "rebinds by index when the database was backed up before the key" testWalletBackupBeforeKey it "discards a key imported before the database is restored" testWalletImportThenRestore @@ -245,3 +246,24 @@ testWalletImportThenRestore ps = withNewTestChat ps "alice" aliceProfile $ \alic alice ##> "/_wallet bind" rows <- nameRows alice map fst rows `shouldBe` ["m/44'/60'/0'/0/0", "m/44'/60'/0'/0/1"] + +testWalletBindLimits :: HasCallStack => TestParams -> IO () +testWalletBindLimits ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice <## "no account for this profile" + alice ##> "/_wallet bind" + _ <- nameRows alice + -- a profile that has an account asks for another one by number + alice ##> "/_wallet bind" + alice <## "bad chat command: this profile already has an account" + alice ##> "/create user alisa" + showActiveUser alice "alisa" + alice ##> "/_wallet bind 2147483647" + rows <- nameRows alice + alice <## "also on same seed: alice" + map fst rows `shouldBe` ["m/44'/60'/2147483647'/0/0", "m/44'/60'/2147483647'/0/1"] + -- the counter is past what BIP-32 can harden, where it would repeat account 0 + alice ##> "/create user carol" + showActiveUser alice "carol" + alice ##> "/_wallet bind" + alice <## "bad chat command: no free account on this key" From 81fd2415a5155a1a461da7feb56583dac3b3494b Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 10 Sep 2026 14:59:33 +0000 Subject: [PATCH 18/67] docs: rfc for the keys that own names Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- docs/rfcs/2026-09-10-name-ownership-keys.md | 152 ++++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 docs/rfcs/2026-09-10-name-ownership-keys.md diff --git a/docs/rfcs/2026-09-10-name-ownership-keys.md b/docs/rfcs/2026-09-10-name-ownership-keys.md new file mode 100644 index 0000000000..32f740f3f1 --- /dev/null +++ b/docs/rfcs/2026-09-10-name-ownership-keys.md @@ -0,0 +1,152 @@ +# Keys that own names + +## Problem + +A name bought in the app has to be owned by an address, and the client has to be +able to derive that address again after a restart, after a database restore, or +on a new device. Without that, a bought name is lost the moment the device is. + +Buying a name is not in this PR. The key that will own it is, so that the key +material can be reviewed and merged on its own, ahead of the names protocol, the +registrar and signing. + +The wider design is `2026-08-18-in-app-name-purchase-mvp.md`, which lands with +the names work. This document covers only what ships here. + +## Design + +One BIP-39 seed per device, one BIP-44 account per chat profile, one key per name. + +``` +seed (BIP-39) +└── profile account i + └── m/44'/60'/i'/0/k one key per name; k = 0 is the profile's first +``` + +Nothing here is a custom layout: `account` and `address_index` are what BIP-44 +has those levels for, so the addresses line up with wallets people already use. +The tests pin that against the standard `abandon ... about` mnemonic: + +``` +m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 MetaMask account 1 +m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0 MetaMask account 2 +m/44'/60'/1'/0/0 0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265 Ledger Live account 2 +``` + +So profile 0's names are MetaMask's account list in order, and each profile's +first name is the matching Ledger Live account. Both exports are importable +there: the mnemonic as a recovery phrase, a single secret as a private key. + +**Why not one key per profile.** Exporting it would hand over every name that +profile owns, and the resolver keeps one nonce per signer, so a shared key would +serialise every name's record edits behind one counter. An index per name avoids +both. A name's secret is a leaf, with no chain code, so exporting it hands over +that name and nothing else. + +**Why the account level is not hashed from the profile.** An index derived from +the display name would survive a restore in any profile order, and would remove +the index gaps that disclose a hidden profile. It would also break when a profile +is renamed, and it would take profile accounts off the list MetaMask and Ledger +Live enumerate, which is the compatibility the layout is for. The account stays a +counter, and a profile that needs a specific one asks for it. + +Purpose `5564'` is where stealth addresses will attach, at the profile level. +This PR uses purpose `44'` only, so the two do not meet. + +## Commands + +API only. Nothing here is user facing: the names commands will call these, and +the user-facing surface is `/name keys ...` in the wider design. + +``` +/_wallet the first two name addresses of this profile's + account, and the other profiles on the same seed, + by name +/_wallet create generate the seed. Refused if the device has one +/_wallet import store a seed from a phrase. Refused if the device has one +/_wallet bind take the next free account +/_wallet bind claim one account. Refused if another profile holds it +/_wallet export the seed mnemonic +/_wallet export one derived secret, as 0x and 64 hex digits +/_wallet delete delete the seed, unbinding every profile +``` + +Creating the seed and claiming an account are separate commands. `create` and +`import` bind no profile, so no profile is ever put on an account it did not ask +for. Neither is done at startup or as a side effect of reading. + +Wallet commands are not forwarded to a remote host: the recovery phrase must not +leave the device, and the raw command would be logged there. + +## Schema + +```sql +CREATE TABLE wallet_seeds ( + wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, + seed BLOB NOT NULL, + next_account_index INTEGER NOT NULL DEFAULT 0, + single_seed INTEGER NOT NULL DEFAULT 1 +); +ALTER TABLE users ADD COLUMN wallet_seed_id INTEGER REFERENCES wallet_seeds; +ALTER TABLE users ADD COLUMN wallet_account_index INTEGER; +``` + +`next_account_index` is a high-water mark, deliberately not +`MAX(users.wallet_account_index)`. After a restore from the phrase alone that +column is empty while accounts already hold names, so a new profile would +silently reuse a recovered account's keys. + +The table models several seeds because the follow-up needs them. One per device +is `single_seed` plus a unique index on it, which multi-seed drops with a +`DROP INDEX` and a `DROP COLUMN`. It is a named index rather than an inline +`UNIQUE` for exactly that reason: SQLite cannot drop a `UNIQUE` column or its +automatic index, so the inline form would have forced a table rebuild. + +The migration is `20260908_wallet_seeds`, not the prototype's `20260818`. +Migrations are sorted by name and the runner requires the ones a database has +applied to be a prefix of the list, so a name sorting before `20260822_forward_link` +would give every existing database a state error. The follow-up migrations need +renaming past `20260908` for the same reason. + +## What the phrase does not carry + +A phrase carries entropy and nothing else. Two things are not in it, not on +chain, and not derivable. + +**Which profile held which account.** A chat database backed up after the seed +carries the binding, and restoring it is the whole story. A backup older than the +seed comes back with the profiles and no binding, and nothing says which profile +was account 0. `/_wallet bind ` is how the user says so, and the counter +moves past what is claimed by hand. + +**Which accounts are already taken.** `next_account_index` starts at 0 after an +import, so `/_wallet bind` with no account can hand out one that already owns +names. Only a scan of owned names can restore the mark, and that lands with the +registrar. + +## Scope + +Not here, and unchanged from the prototype: buying a name, the names protocol, +the registrar, signing, `wallet_name_keys`, the recovery scan, several seeds per +device, stealth addresses. + +`/_wallet` shows the first two addresses of the active profile's account, which +is enough to check the derivation against another wallet. Once `wallet_name_keys` +exists it will show the names actually held instead. + +## What is verified + +Each of these is a test, not a claim. + +- The three addresses and the private key above reproduce from the standard + mnemonic, so a name bought here is reachable from any BIP-44 wallet. +- A secret whose first byte is zero keeps its 64 hex digits. +- Nothing is created until asked for, and a second create or import is refused. +- A second profile gets its own account, and its addresses do not intersect the + first profile's. +- The key and the addresses survive a restart. +- A database backed up after the seed needs no import; one backed up before it is + rebound by index and every address comes back; a seed imported before the + restore is replaced by what the backup held. +- A profile binds once, and only to an account BIP-32 can harden. +- An account index at or above 2^31 is refused rather than folded onto a low one. From 3ad17ca4774d1a1eab830af942676ed33a8b01af Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 10 Sep 2026 16:29:06 +0000 Subject: [PATCH 19/67] core: pin simplexmq for the BIP-44 path, and use it ethereumPath now takes the address index, so the path a name key sits at is built once in the library rather than again here. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Wallet.hs | 9 +++------ 3 files changed, 5 insertions(+), 8 deletions(-) diff --git a/cabal.project b/cabal.project index 0a6efd7a2f..663a118ffd 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: a23da20fb53eb6e43012aa5ee305ed41f7f6f6ab + tag: 22987046875e2a7e40ef3c1d3cba89879b437581 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 7b99c12b39..80bc848215 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."a23da20fb53eb6e43012aa5ee305ed41f7f6f6ab" = "1s0m80i0z7b5x7xx2rqkzqv28zaxmmfm4x1vkgka1vb9qxmpb1p1"; + "https://github.com/simplex-chat/simplexmq.git"."22987046875e2a7e40ef3c1d3cba89879b437581" = "1xx1id2xq2l0wk9qqbwnykpyw5w7sicq28rwg44c7rxpx7gyj6dx"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index a5cfa61ce3..304de1f807 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -30,6 +30,7 @@ import Data.Word (Word32) 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) type SeedId = Int64 @@ -64,15 +65,11 @@ seedMaster s = do m <- B39.entropyToMnemonic (wsEntropy s) B32.masterKey (B39.mnemonicToSeed m "") --- | So the phrase reaches the same addresses in other wallets. -nameKeyPath :: AccountIndex -> NameIndex -> [Word32] -nameKeyPath acc nm = [B32.hardened 44, B32.hardened 60, B32.hardened acc, 0, nm] - renderNameKeyPath :: AccountIndex -> NameIndex -> Text -renderNameKeyPath acc nm = decodeLatin1 . B32.renderPath $ nameKeyPath acc nm +renderNameKeyPath acc nm = decodeLatin1 . B32.renderPath $ ethereumPath acc nm deriveNameKey :: B32.ExtendedKey -> AccountIndex -> NameIndex -> Either String S.PrivateKey -deriveNameKey master acc nm = B32.xkKey <$> B32.derivePath master (nameKeyPath acc nm) +deriveNameKey master acc nm = B32.xkKey <$> B32.derivePath master (ethereumPath acc nm) -- | As wallets take it when a key is imported on its own. nameKeySecret :: S.PrivateKey -> ByteString From 472ee5ef1012f9c312ea1163d7ed20824e4e2fe9 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 10 Sep 2026 16:44:15 +0000 Subject: [PATCH 20/67] core: refuse an account index BIP-32 cannot harden, whichever way it arrives The counter path checked it, the explicit one did not, and the parser is not the only caller: processChatCommand takes APIWalletBind from library callers too. hardened leaves an index at or above 2^31 alone, so account i and i + 2^31 are the same key while the duplicate check compares the stored integers, and two profiles could hold one account's names. The rfc now says what hiding a profile from /_wallet does and does not protect: the seed is one per device, so whoever unlocks any profile can derive every account. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- docs/rfcs/2026-09-10-name-ownership-keys.md | 12 +++++++++ src/Simplex/Chat/Store/Wallets.hs | 30 +++++++++++---------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/docs/rfcs/2026-09-10-name-ownership-keys.md b/docs/rfcs/2026-09-10-name-ownership-keys.md index 32f740f3f1..85037134c9 100644 --- a/docs/rfcs/2026-09-10-name-ownership-keys.md +++ b/docs/rfcs/2026-09-10-name-ownership-keys.md @@ -124,6 +124,18 @@ import, so `/_wallet bind` with no account can hand out one that already owns names. Only a scan of owned names can restore the mark, and that lands with the registrar. +## Hidden profiles + +`/_wallet` names the other profiles on the seed and never numbers them, so a +hidden profile leaves no gap in a list of account indexes. + +That hides its existence from the listing, and nothing more. The seed is one per +device, so whoever unlocks any profile can export the phrase and derive every +account, including a hidden profile's. A hidden profile's names are not +pseudonymous against someone who already holds the device and one password. This +is a consequence of one seed per device, and a key per profile rather than per +device is what would change it. + ## Scope Not here, and unchanged from the prototype: buying a name, the names protocol, diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index 99eb4b226e..6d2b659764 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -88,20 +88,22 @@ bindAccountIndex db user@User {userId} sId = \case if acct >= 0x80000000 then pure $ Left "no free account on this key" else Right () <$ bindUser db userId sId acct - Just acct -> do - taken <- - maybeFirstRow fromOnly $ - DB.query - db - "SELECT 1 FROM users WHERE wallet_seed_id = ? AND wallet_account_index = ? AND user_id != ?" - (sId, fromIntegral acct :: Int64, userId) - case (taken :: Maybe Int64) of - Just _ -> pure $ Left "another profile uses this account" - Nothing -> do - bindUser db userId sId (fromIntegral acct) - -- the counter moves past it, so the next profile is not handed the same one - setNextAccountIndex db sId (fromIntegral acct + 1) - pure $ Right () + Just acct + | acct >= 0x80000000 -> pure $ Left "account index too large" + | otherwise -> do + taken <- + maybeFirstRow fromOnly $ + DB.query + db + "SELECT 1 FROM users WHERE wallet_seed_id = ? AND wallet_account_index = ? AND user_id != ?" + (sId, fromIntegral acct :: Int64, userId) + case (taken :: Maybe Int64) of + Just _ -> pure $ Left "another profile uses this account" + Nothing -> do + bindUser db userId sId (fromIntegral acct) + -- the counter moves past it, so the next profile is not handed the same one + setNextAccountIndex db sId (fromIntegral acct + 1) + pure $ Right () -- | So two profiles cannot be handed the same account. takeAccountIndex :: DB.Connection -> SeedId -> IO Int64 From f45647e1138b665d3978020c54d3806a91b898c5 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 10 Sep 2026 17:15:38 +0000 Subject: [PATCH 21/67] core: pin simplexmq with the wordlist embedded from the upstream file Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index 663a118ffd..2c78a49b11 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 22987046875e2a7e40ef3c1d3cba89879b437581 + tag: e6597f28b0bc7ed93b75635890b139435e6427f6 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 80bc848215..225b67b12b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."22987046875e2a7e40ef3c1d3cba89879b437581" = "1xx1id2xq2l0wk9qqbwnykpyw5w7sicq28rwg44c7rxpx7gyj6dx"; + "https://github.com/simplex-chat/simplexmq.git"."e6597f28b0bc7ed93b75635890b139435e6427f6" = "0pkmj6g85jjx2wcxbfh6p2zp4r9r3zcb6dry3j1mxazm7pkfs5rs"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; From 044e0fc0d64d3dcbe7120223d60531d0a5fb0c6e Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 11 Sep 2026 06:51:10 +0000 Subject: [PATCH 22/67] docs: cut the rfc to what stands on its own Drops the references to other documents and to what is or is not in a pull request, so the design reads without them, and the list of tests, which restated their names and would drift. 164 lines to 134, against a median of 117 for the rfcs here that define schema. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- docs/rfcs/2026-09-10-name-ownership-keys.md | 156 ++++++++------------ 1 file changed, 63 insertions(+), 93 deletions(-) diff --git a/docs/rfcs/2026-09-10-name-ownership-keys.md b/docs/rfcs/2026-09-10-name-ownership-keys.md index 85037134c9..812f21a967 100644 --- a/docs/rfcs/2026-09-10-name-ownership-keys.md +++ b/docs/rfcs/2026-09-10-name-ownership-keys.md @@ -2,20 +2,17 @@ ## Problem -A name bought in the app has to be owned by an address, and the client has to be -able to derive that address again after a restart, after a database restore, or -on a new device. Without that, a bought name is lost the moment the device is. +A name bought in the app has to be owned by an address, and the client has to +derive that address again after a restart, after a database restore, or on a new +device. Otherwise the name is lost with the device. -Buying a name is not in this PR. The key that will own it is, so that the key -material can be reviewed and merged on its own, ahead of the names protocol, the -registrar and signing. - -The wider design is `2026-08-18-in-app-name-purchase-mvp.md`, which lands with -the names work. This document covers only what ships here. +Buying is not here. The key lands first, so the key material can be reviewed on +its own, before the names protocol, the registrar and signing. ## Design -One BIP-39 seed per device, one BIP-44 account per chat profile, one key per name. +One BIP-39 seed per device, one BIP-44 account per chat profile, one key per +name. ``` seed (BIP-39) @@ -23,9 +20,9 @@ seed (BIP-39) └── m/44'/60'/i'/0/k one key per name; k = 0 is the profile's first ``` -Nothing here is a custom layout: `account` and `address_index` are what BIP-44 -has those levels for, so the addresses line up with wallets people already use. -The tests pin that against the standard `abandon ... about` mnemonic: +This is plain BIP-44: `account` and `address_index` are what those levels are +for, so the addresses match wallets people already use. The tests pin that +against the standard `abandon ... about` mnemonic: ``` m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 MetaMask account 1 @@ -34,49 +31,45 @@ m/44'/60'/1'/0/0 0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265 Ledger Live acco ``` So profile 0's names are MetaMask's account list in order, and each profile's -first name is the matching Ledger Live account. Both exports are importable -there: the mnemonic as a recovery phrase, a single secret as a private key. +first name is the matching Ledger Live account. The mnemonic imports there as a +recovery phrase, a single secret as a private key. **Why not one key per profile.** Exporting it would hand over every name that -profile owns, and the resolver keeps one nonce per signer, so a shared key would -serialise every name's record edits behind one counter. An index per name avoids -both. A name's secret is a leaf, with no chain code, so exporting it hands over -that name and nothing else. +profile owns, and the resolver keeps one nonce per signer, so one key would +serialise every name's record edits behind one counter. A name's secret is a +leaf, with no chain code, so exporting it hands over that name only. -**Why the account level is not hashed from the profile.** An index derived from -the display name would survive a restore in any profile order, and would remove -the index gaps that disclose a hidden profile. It would also break when a profile -is renamed, and it would take profile accounts off the list MetaMask and Ledger -Live enumerate, which is the compatibility the layout is for. The account stays a -counter, and a profile that needs a specific one asks for it. +**Why the account is a counter and not a hash of the profile.** A hashed index +would survive a restore in any profile order, and would leave no gap to disclose +a hidden profile. It would also change when a profile is renamed, and it would +move profile accounts off the list MetaMask and Ledger Live enumerate, which is +the point of the layout. -Purpose `5564'` is where stealth addresses will attach, at the profile level. -This PR uses purpose `44'` only, so the two do not meet. +Stealth addresses will attach at purpose `5564'`, at the profile level. Only +purpose `44'` is used here. ## Commands -API only. Nothing here is user facing: the names commands will call these, and -the user-facing surface is `/name keys ...` in the wider design. +Internal API. The names commands will call these; users will not. ``` -/_wallet the first two name addresses of this profile's - account, and the other profiles on the same seed, - by name -/_wallet create generate the seed. Refused if the device has one -/_wallet import store a seed from a phrase. Refused if the device has one -/_wallet bind take the next free account -/_wallet bind claim one account. Refused if another profile holds it -/_wallet export the seed mnemonic +/_wallet this profile's first two name addresses, and + the other profiles on the seed, by name +/_wallet create generate the seed. Refused if the device has one +/_wallet import store a seed. Refused if the device has one +/_wallet bind take the next free account +/_wallet bind claim one. Refused if another profile holds it +/_wallet export the seed mnemonic /_wallet export one derived secret, as 0x and 64 hex digits -/_wallet delete delete the seed, unbinding every profile +/_wallet delete delete the seed, unbinding every profile ``` -Creating the seed and claiming an account are separate commands. `create` and -`import` bind no profile, so no profile is ever put on an account it did not ask -for. Neither is done at startup or as a side effect of reading. +Creating the seed and claiming an account are separate. `create` and `import` +bind no profile, so no profile is put on an account it did not ask for. Neither +runs at startup or as a side effect of reading. -Wallet commands are not forwarded to a remote host: the recovery phrase must not -leave the device, and the raw command would be logged there. +None of them is forwarded to a remote host: the recovery phrase must not leave +the device, and the raw command would be logged there. ## Schema @@ -93,72 +86,49 @@ ALTER TABLE users ADD COLUMN wallet_account_index INTEGER; `next_account_index` is a high-water mark, deliberately not `MAX(users.wallet_account_index)`. After a restore from the phrase alone that -column is empty while accounts already hold names, so a new profile would -silently reuse a recovered account's keys. +column is empty while accounts already hold names, so a new profile would reuse +a recovered account's keys. -The table models several seeds because the follow-up needs them. One per device -is `single_seed` plus a unique index on it, which multi-seed drops with a -`DROP INDEX` and a `DROP COLUMN`. It is a named index rather than an inline -`UNIQUE` for exactly that reason: SQLite cannot drop a `UNIQUE` column or its -automatic index, so the inline form would have forced a table rebuild. +The table models several seeds, which a later change needs. One per device is +`single_seed` and a unique index on it, lifted later by a `DROP INDEX` and a +`DROP COLUMN`. It is a named index rather than an inline `UNIQUE` because SQLite +cannot drop a `UNIQUE` column or its automatic index, which would force a table +rebuild. -The migration is `20260908_wallet_seeds`, not the prototype's `20260818`. -Migrations are sorted by name and the runner requires the ones a database has -applied to be a prefix of the list, so a name sorting before `20260822_forward_link` -would give every existing database a state error. The follow-up migrations need -renaming past `20260908` for the same reason. +Migrations are sorted by name, and the runner requires the ones a database has +applied to be a prefix of that list. A later wallet migration has to sort after +`20260908_wallet_seeds`, or every existing database fails to start. ## What the phrase does not carry A phrase carries entropy and nothing else. Two things are not in it, not on chain, and not derivable. -**Which profile held which account.** A chat database backed up after the seed -carries the binding, and restoring it is the whole story. A backup older than the -seed comes back with the profiles and no binding, and nothing says which profile -was account 0. `/_wallet bind ` is how the user says so, and the counter -moves past what is claimed by hand. +**Which profile held which account.** A database backed up after the seed +carries the binding. One backed up before it comes back with the profiles and no +binding, and nothing records which profile was account 0. `/_wallet bind +` is how the user states it, and the counter moves past what is claimed. -**Which accounts are already taken.** `next_account_index` starts at 0 after an -import, so `/_wallet bind` with no account can hand out one that already owns -names. Only a scan of owned names can restore the mark, and that lands with the -registrar. +**Which accounts are taken.** `next_account_index` starts at 0 after an import, +so `/_wallet bind` with no account can hand out one that already owns names. +Only a scan of owned names can restore the mark. ## Hidden profiles `/_wallet` names the other profiles on the seed and never numbers them, so a hidden profile leaves no gap in a list of account indexes. -That hides its existence from the listing, and nothing more. The seed is one per -device, so whoever unlocks any profile can export the phrase and derive every -account, including a hidden profile's. A hidden profile's names are not -pseudonymous against someone who already holds the device and one password. This -is a consequence of one seed per device, and a key per profile rather than per -device is what would change it. +That hides it from the listing and nothing more. The seed is one per device, so +whoever unlocks any profile can export the phrase and derive every account, +including a hidden profile's. Hiding a profile does not make its names +pseudonymous against someone holding the device and one password. A key per +profile rather than per device is what would change that. ## Scope -Not here, and unchanged from the prototype: buying a name, the names protocol, -the registrar, signing, `wallet_name_keys`, the recovery scan, several seeds per -device, stealth addresses. +Not here: buying a name, the names protocol, the registrar, signing, the +recovery scan, several seeds per device, stealth addresses. -`/_wallet` shows the first two addresses of the active profile's account, which -is enough to check the derivation against another wallet. Once `wallet_name_keys` -exists it will show the names actually held instead. - -## What is verified - -Each of these is a test, not a claim. - -- The three addresses and the private key above reproduce from the standard - mnemonic, so a name bought here is reachable from any BIP-44 wallet. -- A secret whose first byte is zero keeps its 64 hex digits. -- Nothing is created until asked for, and a second create or import is refused. -- A second profile gets its own account, and its addresses do not intersect the - first profile's. -- The key and the addresses survive a restart. -- A database backed up after the seed needs no import; one backed up before it is - rebound by index and every address comes back; a seed imported before the - restore is replaced by what the backup held. -- A profile binds once, and only to an account BIP-32 can harden. -- An account index at or above 2^31 is refused rather than folded onto a low one. +`/_wallet` shows the first two addresses of the active profile's account, enough +to check the derivation against another wallet. It will show the names actually +held once those are recorded. From bcfa74ed9c2353bbadada2418c29173cf82874ae Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 11 Sep 2026 07:09:56 +0000 Subject: [PATCH 23/67] docs: what the stealth meta address is for Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- docs/rfcs/2026-09-10-name-ownership-keys.md | 7 +++++++ 1 file changed, 7 insertions(+) diff --git a/docs/rfcs/2026-09-10-name-ownership-keys.md b/docs/rfcs/2026-09-10-name-ownership-keys.md index 812f21a967..7f9df0daae 100644 --- a/docs/rfcs/2026-09-10-name-ownership-keys.md +++ b/docs/rfcs/2026-09-10-name-ownership-keys.md @@ -48,6 +48,13 @@ the point of the layout. Stealth addresses will attach at purpose `5564'`, at the profile level. Only purpose `44'` is used here. +The plan is one meta address per profile: a spend key and a viewing key, whose +public halves are published with the chat profile, opt-in. A sender derives a +fresh destination from it without a handshake, so one meta address serves any +number of names received that way, and those keys are not at a derivation path. +That is why it belongs at the profile level, while the names a profile buys sit +at the address level. + ## Commands Internal API. The names commands will call these; users will not. From bf5b9d5af4e34fc4b5985fc94dbc37395d5eb51d Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 11 Sep 2026 07:16:20 +0000 Subject: [PATCH 24/67] docs: the wallet is not only for names The seed, the accounts and the derivation are a wallet. Owning a name is the first thing it is used for, not what it is, and the title and the problem it solves now say so. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- ...ship-keys.md => 2026-09-10-wallet-keys.md} | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) rename docs/rfcs/{2026-09-10-name-ownership-keys.md => 2026-09-10-wallet-keys.md} (89%) diff --git a/docs/rfcs/2026-09-10-name-ownership-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md similarity index 89% rename from docs/rfcs/2026-09-10-name-ownership-keys.md rename to docs/rfcs/2026-09-10-wallet-keys.md index 7f9df0daae..cc5bd1c671 100644 --- a/docs/rfcs/2026-09-10-name-ownership-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -1,13 +1,15 @@ -# Keys that own names +# Wallet keys ## Problem -A name bought in the app has to be owned by an address, and the client has to -derive that address again after a restart, after a database restore, or on a new -device. Otherwise the name is lost with the device. +The client needs keys of its own, outside the messaging protocol, at addresses +it can derive again after a restart, after a database restore, or on a new +device. Otherwise whatever an address holds is lost with the device. -Buying is not here. The key lands first, so the key material can be reviewed on -its own, before the names protocol, the registrar and signing. +The first of those is name ownership: a name bought in the app is owned by one +of these addresses. Buying is not here. The keys land first, so the key material +can be reviewed on its own, before the names protocol, the registrar and +signing. ## Design @@ -51,9 +53,9 @@ purpose `44'` is used here. The plan is one meta address per profile: a spend key and a viewing key, whose public halves are published with the chat profile, opt-in. A sender derives a fresh destination from it without a handshake, so one meta address serves any -number of names received that way, and those keys are not at a derivation path. -That is why it belongs at the profile level, while the names a profile buys sit -at the address level. +number of incoming destinations, and those keys are not at a derivation path. +That is why it belongs at the profile level, while what a profile buys sits at +the address level. ## Commands From af1349160e2cf713ad05f9aa8e9dce725f0368a9 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 11 Sep 2026 07:33:29 +0000 Subject: [PATCH 25/67] docs: the reason for a key per name is that owners are public One address owning several names links them: whoever knows one name can read its owner and find the others. That is the reason, not the narrower export. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- docs/rfcs/2026-09-10-wallet-keys.md | 13 +++++++------ 1 file changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index cc5bd1c671..55bc5508c8 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -6,8 +6,8 @@ The client needs keys of its own, outside the messaging protocol, at addresses it can derive again after a restart, after a database restore, or on a new device. Otherwise whatever an address holds is lost with the device. -The first of those is name ownership: a name bought in the app is owned by one -of these addresses. Buying is not here. The keys land first, so the key material +The first use case is name ownership: a name bought in the app is owned by one +of these addresses. Buying is not implemented yet: The keys land first, so the key material can be reviewed on its own, before the names protocol, the registrar and signing. @@ -36,10 +36,11 @@ So profile 0's names are MetaMask's account list in order, and each profile's first name is the matching Ledger Live account. The mnemonic imports there as a recovery phrase, a single secret as a private key. -**Why not one key per profile.** Exporting it would hand over every name that -profile owns, and the resolver keeps one nonce per signer, so one key would -serialise every name's record edits behind one counter. A name's secret is a -leaf, with no chain code, so exporting it hands over that name only. +**Why not one key per profile.** A name's owner is public, so an address that +owns several names links them: whoever knows one of them can read its owner and +find the rest. A key per name leaves no such link. It also keeps an export +narrow, as a name's secret is a leaf, with no chain code, so handing it over +hands over that name only. **Why the account is a counter and not a hash of the profile.** A hashed index would survive a restore in any profile order, and would leave no gap to disclose From 05508c0edd04e43e3cf8c29a2f8aab08b8408a30 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 11 Sep 2026 07:35:39 +0000 Subject: [PATCH 26/67] docs: give the reason for the counter before what it costs Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- docs/rfcs/2026-09-10-wallet-keys.md | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 55bc5508c8..f6a7b53a52 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -42,11 +42,14 @@ find the rest. A key per name leaves no such link. It also keeps an export narrow, as a name's secret is a leaf, with no chain code, so handing it over hands over that name only. -**Why the account is a counter and not a hash of the profile.** A hashed index -would survive a restore in any profile order, and would leave no gap to disclose -a hidden profile. It would also change when a profile is renamed, and it would -move profile accounts off the list MetaMask and Ledger Live enumerate, which is -the point of the layout. +**Why the account is a counter and not a hash of the profile.** A counter keeps +every profile on the account list MetaMask and Ledger Live enumerate, which is +the compatibility this layout is for. A hash of the display name would not, and +it would change whenever a profile is renamed. + +It costs two things a hash would give: the same account after a restore whatever +order the profiles were recreated in, and indexes with no gap to disclose a +hidden profile. Stealth addresses will attach at purpose `5564'`, at the profile level. Only purpose `44'` is used here. From 309358a162ce5f516dbb4d509b46d498f940c31b Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 11 Sep 2026 12:12:41 +0000 Subject: [PATCH 27/67] core: names are the device's, so drop the per-profile account A name's profile is the record it resolves to, not the key that owns it, so the account level was carrying a mapping nothing needs. It was also the only source of the profile to account ambiguity after a restore, of /_wallet bind, and of the index gap and the refusal that told a visible profile an account was held by one it cannot see. All of it goes. Names sit in account 0 from index 1. Index 0 is left unused so neither the names nor the profile accounts, which start at 1, claim the origin of both dimensions. users gains no columns at all now, so the migration is one CREATE TABLE. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- bots/src/API/Docs/Commands.hs | 1 - src/Simplex/Chat/Controller.hs | 8 +- src/Simplex/Chat/Library/Commands.hs | 35 ++- .../Migrations/M20260908_wallet_seeds.hs | 13 +- .../Store/Postgres/Migrations/chat_schema.sql | 15 +- .../Migrations/M20260908_wallet_seeds.hs | 17 +- .../Store/SQLite/Migrations/chat_schema.sql | 13 +- src/Simplex/Chat/Store/Wallets.hs | 101 +-------- src/Simplex/Chat/View.hs | 9 +- src/Simplex/Chat/Wallet.hs | 19 +- tests/WalletTests.hs | 211 ++++-------------- 11 files changed, 101 insertions(+), 341 deletions(-) diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 3673ca7c86..0be6b110a7 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -451,7 +451,6 @@ undocumentedCommands = "APIVerifyGroupMember", "APIVerifyToken", "APIWallet", - "APIWalletBind", "APIWalletCreate", "APIWalletDelete", "APIWalletExportDerivedSecret", diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 179d7be93e..ae3ca95029 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -68,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 (AccountIndex, NameIndex) +import Simplex.Chat.Wallet (NameIndex) import Simplex.Chat.Util (liftIOEither) import Simplex.FileTransfer.Description (FileDescriptionURI) import Simplex.Messaging.Server.Information (ServerPublicInfo) @@ -418,11 +418,10 @@ data ChatCommand | 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 - | APIWalletBind {boundAccountIndex :: Maybe AccountIndex} | APIWalletCreate | APIWalletImport {recoveryPhrase :: Text} | APIWalletExportSeedMnemonic - | APIWalletExportDerivedSecret {accountIndex :: AccountIndex, nameIndex :: NameIndex} + | APIWalletExportDerivedSecret {nameIndex :: NameIndex} | APIWalletDelete | APISendCallInvitation ContactId CallType | SendCallInvitation ContactName CallType @@ -750,7 +749,6 @@ allowRemoteCommand = \case ExecChatStoreSQL _ -> False ExecAgentStoreSQL _ -> False APIWallet -> False - APIWalletBind {} -> False APIWalletCreate -> False APIWalletImport _ -> False APIWalletExportSeedMnemonic -> False @@ -857,7 +855,7 @@ 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)], walletProfiles :: [Text]} + | CRWallet {user :: User, walletKeyExists :: Bool, walletKeyPaths :: [(Text, Text)]} | CRWalletSeedMnemonic {user :: User, recoveryPhrase :: Text} | CRWalletDerivedSecret {user :: User, keyPath :: Text, address :: Text, derivedSecret :: Text} | CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 2941802e47..9bb67cc955 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 (bindAccountIndex, createSeed, deleteSeed, getAccountIndex, getDeviceSeed, getSeedProfiles) -import Simplex.Chat.Wallet (AccountIndex, WalletSeed (..), deriveNameKey, importRecoveryKey, nameKeySecret, newSeed, recoveryKeyPhrase, renderNameKeyPath, seedMaster) +import Simplex.Chat.Store.Wallets (createSeed, deleteSeed, getDeviceSeed, getNextNameIndex) +import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), deriveNameKey, importRecoveryKey, nameKeySecret, newSeed, recoveryKeyPhrase, renderNameKeyPath, seedMaster) import Simplex.Messaging.Eth.Address (addressFromPrivateKey) import Simplex.Chat.Call import Simplex.Chat.Controller @@ -1494,17 +1494,10 @@ processChatCommand cxt nm = \case pure $ CRServiceReplyAccepted user (AgentConnId connId) APIWallet -> withUser $ \user -> do withFastStore' getDeviceSeed >>= \case - Nothing -> pure $ CRWallet user False [] [] + Nothing -> pure $ CRWallet user False [] Just seed -> do - acct_ <- withFastStore' (`getAccountIndex` user) - paths <- maybe (pure []) (nameKeyRows seed) acct_ - -- other profiles are named but not numbered, so a hidden one leaves no gap - profiles <- withFastStore' $ \db -> getSeedProfiles db (wsId seed) user - pure $ CRWallet user True paths profiles - APIWalletBind acct_ -> withUser $ \user -> do - seed <- deviceSeed - withFastStore' (\db -> bindAccountIndex db user (wsId seed) acct_) >>= either throwCmdError pure - processChatCommand cxt nm APIWallet + next <- withFastStore' $ \db -> getNextNameIndex db (wsId seed) + CRWallet user True <$> nameKeyRows seed next APIWalletCreate -> withUser $ \_ -> do g <- asks random entropy <- atomically $ newSeed MS256 g @@ -1520,10 +1513,10 @@ processChatCommand cxt nm = \case seed <- deviceSeed phrase <- either throwCmdError pure $ recoveryKeyPhrase seed pure $ CRWalletSeedMnemonic user (safeDecodeUtf8 phrase) - APIWalletExportDerivedSecret acct nameIdx -> withUser $ \user -> do + APIWalletExportDerivedSecret nameIdx -> withUser $ \user -> do seed <- deviceSeed - k <- either throwCmdError pure $ seedMaster seed >>= \m -> deriveNameKey m acct nameIdx - pure $ CRWalletDerivedSecret user (renderNameKeyPath acct nameIdx) (decodeLatin1 . strEncode $ addressFromPrivateKey k) (safeDecodeUtf8 $ nameKeySecret k) + 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) @@ -5470,11 +5463,11 @@ walletNamesShown = 2 deviceSeed :: CM WalletSeed deviceSeed = withFastStore' getDeviceSeed >>= maybe (throwCmdError "no wallet key on this device") pure -nameKeyRows :: WalletSeed -> AccountIndex -> CM [(Text, Text)] -nameKeyRows seed acct = either throwCmdError pure $ do +nameKeyRows :: WalletSeed -> NameIndex -> CM [(Text, Text)] +nameKeyRows seed next = either throwCmdError pure $ do master <- seedMaster seed - forM (take walletNamesShown [0 ..]) $ \nm -> - (renderNameKeyPath acct nm,) . decodeLatin1 . strEncode . addressFromPrivateKey <$> deriveNameKey master acct nm + forM (take walletNamesShown [next ..]) $ \nm -> + (renderNameKeyPath nm,) . decodeLatin1 . strEncode . addressFromPrivateKey <$> deriveNameKey master nm chatCommandP :: Parser ChatCommand chatCommandP = @@ -5594,11 +5587,9 @@ 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 bind " *> (APIWalletBind . Just <$> keyIndexP), - "/_wallet bind" $> APIWalletBind Nothing, "/_wallet create" $> APIWalletCreate, "/_wallet import " *> (APIWalletImport <$> textP), - "/_wallet export " *> (APIWalletExportDerivedSecret <$> keyIndexP <* A.space <*> keyIndexP), + "/_wallet export " *> (APIWalletExportDerivedSecret <$> keyIndexP), "/_wallet export" $> APIWalletExportSeedMnemonic, "/_wallet delete" $> APIWalletDelete, "/_wallet" $> APIWallet, 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 a5117f0873..d8617d0841 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs @@ -13,25 +13,18 @@ CREATE TABLE wallet_seeds ( wallet_seed_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, seed BYTEA NOT NULL, -- see the SQLite migration - next_account_index BIGINT NOT NULL DEFAULT 0, - -- one key per device for now + next_name_index BIGINT NOT NULL DEFAULT 1, + -- one seed per device for now single_seed SMALLINT NOT NULL DEFAULT 1 ); -ALTER TABLE users ADD COLUMN wallet_seed_id BIGINT REFERENCES wallet_seeds ON DELETE RESTRICT; -ALTER TABLE users ADD COLUMN wallet_account_index BIGINT; - CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON wallet_seeds(single_seed); -CREATE INDEX idx_users_wallet_seed_id ON users(wallet_seed_id); |] down_m20260908_wallet_seeds :: Text down_m20260908_wallet_seeds = [r| -DROP INDEX idx_users_wallet_seed_id; - -ALTER TABLE users DROP COLUMN wallet_account_index; -ALTER TABLE users DROP COLUMN wallet_seed_id; +DROP INDEX idx_wallet_seeds_single_seed; DROP TABLE wallet_seeds; |] diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 090e2ebf6d..c5e5fa52e5 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1517,9 +1517,7 @@ CREATE TABLE test_chat_schema.users ( auto_accept_member_contacts smallint DEFAULT 0 NOT NULL, is_user_chat_relay smallint DEFAULT 0 NOT NULL, client_service smallint DEFAULT 0 NOT NULL, - auto_accept_group_invitations smallint DEFAULT 0 NOT NULL, - wallet_seed_id bigint, - wallet_account_index bigint + auto_accept_group_invitations smallint DEFAULT 0 NOT NULL ); @@ -1538,7 +1536,7 @@ ALTER TABLE test_chat_schema.users ALTER COLUMN user_id ADD GENERATED ALWAYS AS CREATE TABLE test_chat_schema.wallet_seeds ( wallet_seed_id bigint NOT NULL, seed bytea NOT NULL, - next_account_index bigint DEFAULT 0 NOT NULL, + next_name_index bigint DEFAULT 1 NOT NULL, single_seed smallint DEFAULT 1 NOT NULL ); @@ -2667,10 +2665,6 @@ CREATE UNIQUE INDEX idx_user_contact_links_group_id ON test_chat_schema.user_con -CREATE INDEX idx_users_wallet_seed_id ON test_chat_schema.users USING btree (wallet_seed_id); - - - CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON test_chat_schema.wallet_seeds USING btree (single_seed); @@ -3350,11 +3344,6 @@ ALTER TABLE ONLY test_chat_schema.user_contact_links -ALTER TABLE ONLY test_chat_schema.users - ADD CONSTRAINT users_wallet_seed_id_fkey FOREIGN KEY (wallet_seed_id) REFERENCES test_chat_schema.wallet_seeds(wallet_seed_id) ON DELETE RESTRICT; - - - ALTER TABLE ONLY test_chat_schema.xftp_file_descriptions ADD CONSTRAINT xftp_file_descriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; 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 a1cbbe0e91..a2fdc2b500 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs @@ -11,27 +11,20 @@ m20260908_wallet_seeds = CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes - -- known issue: after an import this starts at 0, so /_wallet bind with no - -- account can hand out one that already owns names - next_account_index INTEGER NOT NULL DEFAULT 0, - -- one key per device for now + -- 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 single_seed INTEGER NOT NULL DEFAULT 1 ) STRICT; -ALTER TABLE users ADD COLUMN wallet_seed_id INTEGER REFERENCES wallet_seeds ON DELETE RESTRICT; -ALTER TABLE users ADD COLUMN wallet_account_index INTEGER; - CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON wallet_seeds(single_seed); -CREATE INDEX idx_users_wallet_seed_id ON users(wallet_seed_id); |] down_m20260908_wallet_seeds :: Query down_m20260908_wallet_seeds = [sql| -DROP INDEX idx_users_wallet_seed_id; - -ALTER TABLE users DROP COLUMN wallet_account_index; -ALTER TABLE users DROP COLUMN wallet_seed_id; +DROP INDEX idx_wallet_seeds_single_seed; 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 ff0d075675..80c72a42e7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -54,9 +54,7 @@ CREATE TABLE users( auto_accept_member_contacts INTEGER NOT NULL DEFAULT 0, is_user_chat_relay INTEGER NOT NULL DEFAULT 0, client_service INTEGER NOT NULL DEFAULT 0, - auto_accept_group_invitations INTEGER NOT NULL DEFAULT 0, - wallet_seed_id INTEGER REFERENCES wallet_seeds ON DELETE RESTRICT, - wallet_account_index INTEGER, -- 1 for active user + auto_accept_group_invitations INTEGER NOT NULL DEFAULT 0, -- 1 for active user FOREIGN KEY(user_id, local_display_name) REFERENCES display_names(user_id, local_display_name) ON DELETE RESTRICT @@ -857,10 +855,10 @@ CREATE TABLE rcv_roster_transfers( CREATE TABLE wallet_seeds( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes - -- known issue: after an import this starts at 0, so /_wallet bind with no - -- account can hand out one that already owns names - next_account_index INTEGER NOT NULL DEFAULT 0, - -- one key per device for now + -- 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 single_seed INTEGER NOT NULL DEFAULT 1 ) STRICT; CREATE INDEX contact_profiles_index ON contact_profiles( @@ -1398,7 +1396,6 @@ 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 INDEX idx_users_wallet_seed_id ON users(wallet_seed_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 6d2b659764..bd09feb3a0 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -1,33 +1,25 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE LambdaCase #-} -{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE QuasiQuotes #-} module Simplex.Chat.Store.Wallets ( getDeviceSeed, - getAccountIndex, - getSeedProfiles, + getNextNameIndex, createSeed, - bindAccountIndex, deleteSeed, ) where import Data.ByteString (ByteString) import Data.Int (Int64) -import Data.Text (Text) -import Simplex.Chat.Types (User (..)) -import Simplex.Chat.Wallet (AccountIndex, SeedId, WalletSeed (..)) +import Simplex.Chat.Wallet (NameIndex, SeedId, WalletSeed (..)) 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 @@ -38,91 +30,20 @@ getDeviceSeed db = maybeFirstRow toSeed $ DB.query_ db "SELECT wallet_seed_id, seed FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1" -getAccountIndex :: DB.Connection -> User -> IO (Maybe AccountIndex) -getAccountIndex db User {userId} = do - r <- - maybeFirstRow fromOnly $ - DB.query db "SELECT wallet_account_index FROM users WHERE user_id = ?" (Only userId) - pure $ case r of - Just (Just ix) -> Just $ fromIntegral (ix :: Int64) - _ -> Nothing +-- | 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) + ) --- | Hidden profiles are left out, as they are by /users. -getSeedProfiles :: DB.Connection -> SeedId -> User -> IO [Text] -getSeedProfiles db sId User {userId} = - map fromOnly - <$> DB.query - db - [sql| - SELECT local_display_name FROM users - WHERE wallet_seed_id = ? AND user_id != ? AND view_pwd_hash IS NULL - ORDER BY local_display_name - |] - (sId, userId) - -bindUser :: DB.Connection -> Int64 -> SeedId -> Int64 -> IO () -bindUser db uId sId acct = - DB.execute - db - "UPDATE users SET wallet_seed_id = ?, wallet_account_index = ? WHERE user_id = ?" - (sId, acct, uId) - --- | False if the device already has a key. No profile is bound, as which --- account a profile takes is said with bind. +-- | False if the device already has a seed. createSeed :: DB.Connection -> ByteString -> IO Bool createSeed db entropy = getDeviceSeed db >>= \case Just _ -> pure False Nothing -> True <$ DB.execute db "INSERT INTO wallet_seeds (seed) VALUES (?)" (Only $ DB.Binary entropy) --- | Without an account the next free one is taken, which a profile that has --- one does not need: moving to another account is asked for by number. -bindAccountIndex :: DB.Connection -> User -> SeedId -> Maybe AccountIndex -> IO (Either String ()) -bindAccountIndex db user@User {userId} sId = \case - Nothing -> - getAccountIndex db user >>= \case - Just _ -> pure $ Left "this profile already has an account" - Nothing -> do - acct <- takeAccountIndex db sId - -- BIP-32 hardens at 2^31, and every index above it is the same key again - if acct >= 0x80000000 - then pure $ Left "no free account on this key" - else Right () <$ bindUser db userId sId acct - Just acct - | acct >= 0x80000000 -> pure $ Left "account index too large" - | otherwise -> do - taken <- - maybeFirstRow fromOnly $ - DB.query - db - "SELECT 1 FROM users WHERE wallet_seed_id = ? AND wallet_account_index = ? AND user_id != ?" - (sId, fromIntegral acct :: Int64, userId) - case (taken :: Maybe Int64) of - Just _ -> pure $ Left "another profile uses this account" - Nothing -> do - bindUser db userId sId (fromIntegral acct) - -- the counter moves past it, so the next profile is not handed the same one - setNextAccountIndex db sId (fromIntegral acct + 1) - pure $ Right () - --- | So two profiles cannot be handed the same account. -takeAccountIndex :: DB.Connection -> SeedId -> IO Int64 -takeAccountIndex db sId = do - DB.execute db "UPDATE wallet_seeds SET next_account_index = next_account_index + 1 WHERE wallet_seed_id = ?" (Only sId) - maybe 0 (subtract 1) - <$> ( maybeFirstRow fromOnly $ - DB.query db "SELECT next_account_index FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) - ) - -setNextAccountIndex :: DB.Connection -> SeedId -> Int64 -> IO () -setNextAccountIndex db sId acct = - DB.execute - db - "UPDATE wallet_seeds SET next_account_index = ? WHERE wallet_seed_id = ? AND next_account_index < ?" - (acct, sId, acct) - --- | Profiles are unbound first, as the foreign key is ON DELETE RESTRICT. deleteSeed :: DB.Connection -> SeedId -> IO () -deleteSeed db sId = do - DB.execute db "UPDATE users SET wallet_seed_id = NULL, wallet_account_index = NULL WHERE wallet_seed_id = ?" (Only sId) - DB.execute db "DELETE FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) +deleteSeed db sId = DB.execute db "DELETE FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 592f20de31..d02c1d70e4 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -188,14 +188,9 @@ 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 profiles - | not exists -> ttyUser u ["no wallet key"] - | otherwise -> ttyUser u $ keyRows <> [plain $ "also on same seed: " <> T.intercalate ", " profiles | not (null profiles)] + CRWallet u exists paths -> ttyUser u $ if exists then map nameRow paths else ["no wallet key"] where - keyRows - | null paths = ["no account for this profile"] - | otherwise = zipWith nameRow [0 :: Int ..] paths - nameRow k (path, addr) = plain $ "name " <> tshow k <> " " <> path <> " " <> addr + nameRow (path, addr) = plain $ path <> " " <> addr CRWalletSeedMnemonic u phrase -> ttyUser u [plain phrase] CRWalletDerivedSecret u path addr secret -> ttyUser u [plain $ path <> " " <> addr <> " " <> secret] CRGroupCreated u g -> ttyUser u $ viewGroupCreated g testView diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index 304de1f807..d220318413 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -2,12 +2,11 @@ -- | BIP-39 seeds and the keys derived from them. -- --- One account path per profile, one key per name under it. A name's secret is --- a leaf, so exporting it hands over that name only. +-- One key per name. A name's secret is a leaf, so exporting it hands over that +-- name only. module Simplex.Chat.Wallet ( SeedId, WalletSeed (..), - AccountIndex, NameIndex, newSeed, importRecoveryKey, @@ -34,10 +33,8 @@ import Simplex.Messaging.Eth.Address (ethereumPath) type SeedId = Int64 --- | BIP-44 account index, one per chat profile. -type AccountIndex = Word32 - --- | BIP-44 address index, one per name. +-- | 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 data WalletSeed = WalletSeed @@ -65,11 +62,11 @@ seedMaster s = do m <- B39.entropyToMnemonic (wsEntropy s) B32.masterKey (B39.mnemonicToSeed m "") -renderNameKeyPath :: AccountIndex -> NameIndex -> Text -renderNameKeyPath acc nm = decodeLatin1 . B32.renderPath $ ethereumPath acc nm +renderNameKeyPath :: NameIndex -> Text +renderNameKeyPath nm = decodeLatin1 . B32.renderPath $ ethereumPath 0 nm -deriveNameKey :: B32.ExtendedKey -> AccountIndex -> NameIndex -> Either String S.PrivateKey -deriveNameKey master acc nm = B32.xkKey <$> B32.derivePath master (ethereumPath acc nm) +deriveNameKey :: B32.ExtendedKey -> NameIndex -> Either String S.PrivateKey +deriveNameKey master nm = B32.xkKey <$> B32.derivePath master (ethereumPath 0 nm) -- | As wallets take it when a key is imported on its own. nameKeySecret :: S.PrivateKey -> ByteString diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index cd509e5473..cf12eabcee 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -9,9 +9,9 @@ import ChatTests.Utils import Data.ByteString.Char8 (ByteString) import qualified Data.ByteString.Char8 as B import Data.Either (isLeft) -import Data.List (intersect, nub) -import Simplex.Chat.Wallet (AccountIndex, NameIndex, WalletSeed (..), deriveNameKey, importRecoveryKey, nameKeySecret, recoveryKeyPhrase, renderNameKeyPath, seedMaster) +import Data.List (nub) import qualified Simplex.Messaging.Crypto.Secp256k1 as S +import Simplex.Chat.Wallet (NameIndex, WalletSeed (..), deriveNameKey, importRecoveryKey, nameKeySecret, recoveryKeyPhrase, renderNameKeyPath, seedMaster) import Simplex.Messaging.Eth.Address (addressFromPrivateKey) import Test.Hspec hiding (it) import qualified Test.Hspec as Hspec @@ -23,25 +23,23 @@ testPhrase = "abandon abandon abandon abandon abandon abandon abandon abandon ab testSeed :: WalletSeed testSeed = WalletSeed {wsId = 1, wsEntropy = either error id $ importRecoveryKey testPhrase} -nameKey :: AccountIndex -> NameIndex -> Either String S.PrivateKey -nameKey acc nm = seedMaster testSeed >>= \m -> deriveNameKey m acc nm +nameKey :: NameIndex -> Either String S.PrivateKey +nameKey nm = seedMaster testSeed >>= \m -> deriveNameKey m nm walletDerivationTests :: Spec walletDerivationTests = do Hspec.it "name keys line up with other wallets' derivation" $ do - let addrOf i k = either error (show . addressFromPrivateKey) (nameKey i k) - -- MetaMask accounts 1 and 2 for this phrase - addrOf 0 0 `shouldBe` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" - addrOf 0 1 `shouldBe` "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" - -- Ledger Live account 2 for this phrase - addrOf 1 0 `shouldBe` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265" + 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 1 for this phrase, as exported by "Show private key" - either error nameKeySecret (nameKey 0 0) - `shouldBe` "0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727" + -- 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 0 0 `shouldBe` "m/44'/60'/0'/0/0" - renderNameKeyPath 2 7 `shouldBe` "m/44'/60'/2'/0/7" + renderNameKeyPath 1 `shouldBe` "m/44'/60'/0'/0/1" + renderNameKeyPath 7 `shouldBe` "m/44'/60'/0'/0/7" Hspec.it "round-trips the phrase it was imported from" $ recoveryKeyPhrase testSeed `shouldBe` Right testPhrase Hspec.it "refuses a phrase with a bad checksum" $ @@ -49,23 +47,23 @@ walletDerivationTests = do walletTests :: SpecWith TestParams walletTests = do - it "creates no key until asked, then derives addresses" testWalletCreate - it "the key and the addresses come back after a restart" testWalletPersists - it "a second profile gets its own account, on the same key" testWalletSecondProfile + 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 key, and a key can be imported again" testWalletDelete - it "binds a profile to the account it had" testWalletBind - it "binds a profile once, and only to an account BIP-32 can harden" testWalletBindLimits - it "needs no import when the database was backed up after the key" testWalletBackupAfterKey - it "rebinds by index when the database was backed up before the key" testWalletBackupBeforeKey - it "discards a key imported before the database is restored" testWalletImportThenRestore + 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 - ["name", _, path, addr] -> (path, addr) + [path, addr] -> (path, addr) _ -> error $ "unexpected wallet row: " <> l testWalletCreate :: HasCallStack => TestParams -> IO () @@ -78,18 +76,17 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet export" alice <## "bad chat command: no wallet key on this device" alice ##> "/_wallet create" - alice <## "no account for this profile" - alice ##> "/_wallet bind" rows <- nameRows alice - map fst rows `shouldBe` ["m/44'/60'/0'/0/0", "m/44'/60'/0'/0/1"] + 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 create" + alice <## "bad chat command: this device already has a wallet key" testWalletPersists :: HasCallStack => TestParams -> IO () testWalletPersists ps = do rows <- withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create" - alice <## "no account for this profile" - alice ##> "/_wallet bind" nameRows alice -- same database, new session: a name bought at that address must stay reachable withTestChat ps "alice" $ \alice -> do @@ -97,39 +94,24 @@ testWalletPersists ps = do rows' <- nameRows alice rows' `shouldBe` rows -testWalletSecondProfile :: HasCallStack => TestParams -> IO () -testWalletSecondProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> "/_wallet create" - alice <## "no account for this profile" - alice ##> "/_wallet bind" +testWalletSharedByProfiles :: HasCallStack => TestParams -> IO () +testWalletSharedByProfiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do + alice ##> ("/_wallet import " <> B.unpack testPhrase) rows <- nameRows alice - alice ##> "/_wallet export" - phrase <- getTermLine alice alice ##> "/create user alisa" showActiveUser alice "alisa" - -- other profiles are named, never numbered + -- the seed belongs to the device, so a name is not a profile's to see or not alice ##> "/_wallet" - alice <## "no account for this profile" - alice <## "also on same seed: alice" - -- the key belongs to the device, so a profile without an account exports it too - alice ##> "/_wallet export" - alice <## phrase - alice ##> "/_wallet create" - alice <## "bad chat command: this device already has a wallet key" - alice ##> "/_wallet bind" rows' <- nameRows alice - alice <## "also on same seed: alice" - map fst rows' `shouldBe` ["m/44'/60'/1'/0/0", "m/44'/60'/1'/0/1"] - null (map snd rows `intersect` map snd rows') `shouldBe` True + rows' `shouldBe` rows + alice ##> "/_wallet export" + alice <## B.unpack testPhrase testWalletImport :: HasCallStack => TestParams -> IO () testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - -- import binds nothing: which account a profile had is what it is recovering alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "no account for this profile" - alice ##> "/_wallet bind" - alice <## "name 0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94" - alice <## "name 1 m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" + 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 import " <> B.unpack testPhrase) @@ -140,130 +122,35 @@ testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do testWalletExportDerivedSecret :: HasCallStack => TestParams -> IO () testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - -- the secret of a name key needs no profile bound to that account alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "no account for this profile" - alice ##> "/_wallet export 0 0" - alice <## "m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727" - -- an index BIP-32 cannot harden is rejected, not wrapped into another account - alice ##> "/_wallet export 4294967296 0" - alice <## "bad chat command: Failed reading: empty" - -- any path derives, whether or not a profile holds that account - alice ##> "/_wallet export 3 7" - alice <## "m/44'/60'/3'/0/7 0xb8cb8628d242fF621adb05E75b7bF16c9b496740 0x5fa3f03c127d150c82f54291f9989c955c3857a54df7abbb50e28199a0bbaac1" + _ <- nameRows alice + alice ##> "/_wallet export 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 0 15" + alice ##> "/_wallet export 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 4294967296" + alice <## "bad chat command: Failed reading: empty" testWalletDelete :: HasCallStack => TestParams -> IO () testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "no account for this profile" - alice ##> "/_wallet bind" _ <- nameRows alice alice ##> "/_wallet delete" alice <## "no wallet key" - -- deleting unbinds the profile, so a key can be imported again alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "no account for this profile" - --- | A profile says which account was its, as nothing else knows. -testWalletBind :: HasCallStack => TestParams -> IO () -testWalletBind ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "no account for this profile" - alice ##> "/_wallet bind 3" - rows <- nameRows alice - map fst rows `shouldBe` ["m/44'/60'/3'/0/0", "m/44'/60'/3'/0/1"] - alice ##> "/create user alisa" - showActiveUser alice "alisa" - alice ##> "/_wallet bind 3" - alice <## "bad chat command: another profile uses this account" - -- the counter moved past the account bound by hand - alice ##> "/_wallet bind" - rows' <- nameRows alice - alice <## "also on same seed: alice" - map fst rows' `shouldBe` ["m/44'/60'/4'/0/0", "m/44'/60'/4'/0/1"] - --- | The state a chat database backed up before the key restores to. -forgetKey :: HasCallStack => TestCC -> IO () -forgetKey cc = do - cc ##> "/sql chat UPDATE users SET wallet_seed_id = NULL, wallet_account_index = NULL" - cc ##> "/sql chat DELETE FROM wallet_seeds" - -testWalletBackupAfterKey :: HasCallStack => TestParams -> IO () -testWalletBackupAfterKey ps = do - rows <- withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> "/_wallet create" - alice <## "no account for this profile" - alice ##> "/_wallet bind" - nameRows alice - -- the key and the binding are both in the database, so the restore is all of it - withTestChat ps "alice" $ \alice -> do - alice ##> "/_wallet" - rows' <- nameRows alice - rows' `shouldBe` rows - alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "bad chat command: this device already has a wallet key" - -testWalletBackupBeforeKey :: HasCallStack => TestParams -> IO () -testWalletBackupBeforeKey ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "no account for this profile" - alice ##> "/_wallet bind 1" - aliceRows <- nameRows alice - alice ##> "/create user alisa" - showActiveUser alice "alisa" - alice ##> "/_wallet bind 0" - alisaRows <- nameRows alice - alice <## "also on same seed: alice" - forgetKey alice - alice ##> "/_wallet" - alice <## "no wallet key" - -- the phrase alone puts every account back, and each profile says which was its - alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "no account for this profile" - alice ##> "/_wallet bind 0" - alisaRows' <- nameRows alice - alisaRows' `shouldBe` alisaRows - alice ##> "/user alice" - showActiveUser alice "alice (Alice)" - alice ##> "/_wallet bind 1" - aliceRows' <- nameRows alice - alice <## "also on same seed: alisa" - aliceRows' `shouldBe` aliceRows + _ <- nameRows alice + pure () testWalletImportThenRestore :: HasCallStack => TestParams -> IO () testWalletImportThenRestore ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "no account for this profile" - -- restoring the database replaces the key with what the backup held, which is nothing - forgetKey alice + _ <- 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 import " <> B.unpack testPhrase) - alice <## "no account for this profile" - alice ##> "/_wallet bind" rows <- nameRows alice - map fst rows `shouldBe` ["m/44'/60'/0'/0/0", "m/44'/60'/0'/0/1"] - -testWalletBindLimits :: HasCallStack => TestParams -> IO () -testWalletBindLimits ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet import " <> B.unpack testPhrase) - alice <## "no account for this profile" - alice ##> "/_wallet bind" - _ <- nameRows alice - -- a profile that has an account asks for another one by number - alice ##> "/_wallet bind" - alice <## "bad chat command: this profile already has an account" - alice ##> "/create user alisa" - showActiveUser alice "alisa" - alice ##> "/_wallet bind 2147483647" - rows <- nameRows alice - alice <## "also on same seed: alice" - map fst rows `shouldBe` ["m/44'/60'/2147483647'/0/0", "m/44'/60'/2147483647'/0/1"] - -- the counter is past what BIP-32 can harden, where it would repeat account 0 - alice ##> "/create user carol" - showActiveUser alice "carol" - alice ##> "/_wallet bind" - alice <## "bad chat command: no free account on this key" + map fst rows `shouldBe` ["m/44'/60'/0'/0/1", "m/44'/60'/0'/0/2"] From bfa510cdb1adf67c20e88a8e87ba73830ff849f9 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 11 Sep 2026 12:13:20 +0000 Subject: [PATCH 28/67] docs: rfc for names counted per device Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- docs/rfcs/2026-09-10-wallet-keys.md | 148 +++++++++++++--------------- 1 file changed, 68 insertions(+), 80 deletions(-) diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index f6a7b53a52..a8221644eb 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -7,82 +7,76 @@ it can derive again after a restart, after a database restore, or on a new device. Otherwise whatever an address holds is lost with the device. The first use case is name ownership: a name bought in the app is owned by one -of these addresses. Buying is not implemented yet: The keys land first, so the key material -can be reviewed on its own, before the names protocol, the registrar and -signing. +of these addresses. Buying is not implemented yet. The keys land first, so the +key material can be reviewed on its own, before the names protocol, the +registrar and signing. ## Design -One BIP-39 seed per device, one BIP-44 account per chat profile, one key per -name. +One BIP-39 seed per device, and one key per name. ``` -seed (BIP-39) -└── profile account i - └── m/44'/60'/i'/0/k one key per name; k = 0 is the profile's first +m/44'/60'/0'/0/0 unused +m/44'/60'/0'/0/k names, k >= 1, counted per device +m/44'/60'/i'/0/j a profile's own addresses, i >= 1 (later) +m/5564'/60'/i'/... a profile's stealth meta keys (later) ``` -This is plain BIP-44: `account` and `address_index` are what those levels are -for, so the addresses match wallets people already use. The tests pin that +This is plain BIP-44, so the addresses match wallets people already use: the +device's names are MetaMask's account list from its second entry on, and a +profile's first address is the matching Ledger Live account. The tests pin that against the standard `abandon ... about` mnemonic: ``` -m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 MetaMask account 1 m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0 MetaMask account 2 -m/44'/60'/1'/0/0 0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265 Ledger Live account 2 +m/44'/60'/0'/0/2 0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A MetaMask account 3 ``` -So profile 0's names are MetaMask's account list in order, and each profile's -first name is the matching Ledger Live account. The mnemonic imports there as a -recovery phrase, a single secret as a private key. +The mnemonic imports there as a recovery phrase, a single secret as a private +key. `m/44'/60'/0'/0/0` is left unused so that neither names nor profiles claim +the point where both dimensions start. -**Why not one key per profile.** A name's owner is public, so an address that -owns several names links them: whoever knows one of them can read its owner and -find the rest. A key per name leaves no such link. It also keeps an export -narrow, as a name's secret is a leaf, with no chain code, so handing it over -hands over that name only. +**Why a key per name.** A name's owner is public, so an address that owns +several names links them: whoever knows one of them can read its owner and find +the rest. A key per name leaves no such link. It also keeps an export narrow, as +a name's secret is a leaf, with no chain code, so handing it over hands over +that name only. -**Why the account is a counter and not a hash of the profile.** A counter keeps -every profile on the account list MetaMask and Ledger Live enumerate, which is -the compatibility this layout is for. A hash of the display name would not, and -it would change whenever a profile is renamed. +**Why names are not derived per profile.** Which profile a name belongs to is +in the record the name resolves to, which the owner signs, not in the key. A +profile dimension in the path would carry a mapping nothing reads, and it would +have to be reconstructed after a restore, since a phrase does not say which +profile held which account. Counting names per device removes the question. -It costs two things a hash would give: the same account after a restore whatever -order the profiles were recreated in, and indexes with no gap to disclose a -hidden profile. +The cost is compartmentalisation that is not in use. Per-profile accounts would +confine an exposed account-level extended key to one profile's names. Nothing +derives or exports an extended key, and nothing is planned to, so the compartment +has no exit. -Stealth addresses will attach at purpose `5564'`, at the profile level. Only -purpose `44'` is used here. - -The plan is one meta address per profile: a spend key and a viewing key, whose -public halves are published with the chat profile, opt-in. A sender derives a -fresh destination from it without a handshake, so one meta address serves any -number of incoming destinations, and those keys are not at a derivation path. -That is why it belongs at the profile level, while what a profile buys sits at -the address level. +Profiles get their own dimension for what is per profile: their own addresses at +account `i >= 1`, and stealth meta keys at purpose `5564'`. The plan there is one +meta address per profile, a spend key and a viewing key whose public halves are +published with the chat profile, opt-in. A sender derives a fresh destination +from it without a handshake, so one meta address serves any number of incoming +destinations, and those keys are not at a derivation path. That is why it belongs +at the profile level, while what a profile buys sits at the address level. ## Commands Internal API. The names commands will call these; users will not. ``` -/_wallet this profile's first two name addresses, and - the other profiles on the seed, by name -/_wallet create generate the seed. Refused if the device has one -/_wallet import store a seed. Refused if the device has one -/_wallet bind take the next free account -/_wallet bind claim one. Refused if another profile holds it -/_wallet export the seed mnemonic -/_wallet export one derived secret, as 0x and 64 hex digits -/_wallet delete delete the seed, unbinding every profile +/_wallet the next name addresses +/_wallet create generate the seed. Refused if the device has one +/_wallet import store a seed. Refused if the device has one +/_wallet export the seed mnemonic +/_wallet export one derived secret, as 0x and 64 hex digits +/_wallet delete delete the seed ``` -Creating the seed and claiming an account are separate. `create` and `import` -bind no profile, so no profile is put on an account it did not ask for. Neither -runs at startup or as a side effect of reading. - -None of them is forwarded to a remote host: the recovery phrase must not leave -the device, and the raw command would be logged there. +A seed is created only when asked for, never at startup and never as a side +effect of reading. None of these is forwarded to a remote host: the recovery +phrase must not leave the device, and the raw command would be logged there. ## Schema @@ -90,17 +84,15 @@ the device, and the raw command would be logged there. CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, seed BLOB NOT NULL, - next_account_index INTEGER NOT NULL DEFAULT 0, + next_name_index INTEGER NOT NULL DEFAULT 1, single_seed INTEGER NOT NULL DEFAULT 1 ); -ALTER TABLE users ADD COLUMN wallet_seed_id INTEGER REFERENCES wallet_seeds; -ALTER TABLE users ADD COLUMN wallet_account_index INTEGER; ``` -`next_account_index` is a high-water mark, deliberately not -`MAX(users.wallet_account_index)`. After a restore from the phrase alone that -column is empty while accounts already hold names, so a new profile would reuse -a recovered account's keys. +No other table is touched. + +`next_name_index` is a high-water mark, not a count of names held. A name a +device no longer tracks still owns its address, so an index is never reused. The table models several seeds, which a later change needs. One per device is `single_seed` and a unique index on it, lifted later by a `DROP INDEX` and a @@ -114,34 +106,30 @@ applied to be a prefix of that list. A later wallet migration has to sort after ## What the phrase does not carry -A phrase carries entropy and nothing else. Two things are not in it, not on -chain, and not derivable. +A phrase carries entropy and nothing else. Which indexes are already taken is +not in it, not derivable, and not on chain in a form the client can ask for +directly. `next_name_index` starts at 1 after an import, so until a scan of +owned names raises it, the next name would be bought at a path that already owns +one. Buying is not implemented here, so nothing can act on the stale mark yet, +and the scan lands with the registrar that makes buying possible. -**Which profile held which account.** A database backed up after the seed -carries the binding. One backed up before it comes back with the profiles and no -binding, and nothing records which profile was account 0. `/_wallet bind -` is how the user states it, and the counter moves past what is claimed. - -**Which accounts are taken.** `next_account_index` starts at 0 after an import, -so `/_wallet bind` with no account can hand out one that already owns names. -Only a scan of owned names can restore the mark. +Which profile a name belongs to is not lost with the device, because it was +never in the key. It is in the record, and re-pointing a name at a profile is a +signed record edit, not a rebinding of keys. ## Hidden profiles -`/_wallet` names the other profiles on the seed and never numbers them, so a -hidden profile leaves no gap in a list of account indexes. - -That hides it from the listing and nothing more. The seed is one per device, so -whoever unlocks any profile can export the phrase and derive every account, -including a hidden profile's. Hiding a profile does not make its names -pseudonymous against someone holding the device and one password. A key per -profile rather than per device is what would change that. +Nothing about profiles is encoded in the derivation, so the wallet holds nothing +that would disclose a hidden profile. The seed is the device's: whoever unlocks +any profile can export the phrase and derive every name key, hidden profiles +included. A key per profile rather than per device is what would change that. ## Scope Not here: buying a name, the names protocol, the registrar, signing, the -recovery scan, several seeds per device, stealth addresses. +recovery scan, several seeds per device, a profile's own addresses, stealth +addresses. -`/_wallet` shows the first two addresses of the active profile's account, enough -to check the derivation against another wallet. It will show the names actually -held once those are recorded. +`/_wallet` shows the next two name addresses, which is enough to check the +derivation against another wallet. It will show the names actually held once +those are recorded. From 443fd435450841c09fbea6ac2aefa3a58cde3bf0 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 11 Sep 2026 12:28:46 +0000 Subject: [PATCH 29/67] core: one command for the seed, whichever way it arrives create and import differed only in where the entropy came from and already shared one store function, so they are one command with the source named: /_wallet create new, or /_wallet create seed=. Naming it means no seed is generated by typing a prefix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- bots/src/API/Docs/Commands.hs | 1 - docs/rfcs/2026-09-10-wallet-keys.md | 21 ++++++++++++--------- src/Simplex/Chat/Controller.hs | 6 ++---- src/Simplex/Chat/Library/Commands.hs | 16 ++++++---------- tests/WalletTests.hs | 24 ++++++++++++------------ 5 files changed, 32 insertions(+), 36 deletions(-) diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 0be6b110a7..8ac5f43238 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -455,7 +455,6 @@ undocumentedCommands = "APIWalletDelete", "APIWalletExportDerivedSecret", "APIWalletExportSeedMnemonic", - "APIWalletImport", "CheckChatRunning", "ConfirmRemoteCtrl", "ConnectRemoteCtrl", diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index a8221644eb..123bfb568c 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -66,17 +66,20 @@ at the profile level, while what a profile buys sits at the address level. Internal API. The names commands will call these; users will not. ``` -/_wallet the next name addresses -/_wallet create generate the seed. Refused if the device has one -/_wallet import store a seed. Refused if the device has one -/_wallet export the seed mnemonic -/_wallet export one derived secret, as 0x and 64 hex digits -/_wallet delete delete the seed +/_wallet the next name addresses +/_wallet create new generate the seed +/_wallet create seed= take the seed from a phrase +/_wallet export the seed mnemonic +/_wallet export one derived secret, as 0x and 64 hex digits +/_wallet delete delete the seed ``` -A seed is created only when asked for, never at startup and never as a side -effect of reading. None of these is forwarded to a remote host: the recovery -phrase must not leave the device, and the raw command would be logged there. +Creating and importing are one command, because they differ only in where the +entropy comes from, and either is refused if the device already has a seed. The +source is always named, so no seed is generated by typing a prefix. A seed is +created only when asked for, never at startup and never as a side effect of +reading. None of these is forwarded to a remote host: the recovery phrase must +not leave the device, and the raw command would be logged there. ## Schema diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index ae3ca95029..1adbfa3498 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -418,8 +418,7 @@ data ChatCommand | 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 - | APIWalletImport {recoveryPhrase :: Text} + | APIWalletCreate {recoveryPhrase :: Maybe Text} | APIWalletExportSeedMnemonic | APIWalletExportDerivedSecret {nameIndex :: NameIndex} | APIWalletDelete @@ -749,8 +748,7 @@ allowRemoteCommand = \case ExecChatStoreSQL _ -> False ExecAgentStoreSQL _ -> False APIWallet -> False - APIWalletCreate -> False - APIWalletImport _ -> False + APIWalletCreate {} -> False APIWalletExportSeedMnemonic -> False APIWalletExportDerivedSecret {} -> False APIWalletDelete -> False diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 9bb67cc955..b336aca007 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1498,14 +1498,10 @@ processChatCommand cxt nm = \case Just seed -> do next <- withFastStore' $ \db -> getNextNameIndex db (wsId seed) CRWallet user True <$> nameKeyRows seed next - APIWalletCreate -> withUser $ \_ -> do - g <- asks random - entropy <- atomically $ newSeed MS256 g - created <- withFastStore' $ \db -> createSeed db entropy - unless created $ throwCmdError "this device already has a wallet key" - processChatCommand cxt nm APIWallet - APIWalletImport phrase -> withUser $ \_ -> do - entropy <- either (const $ throwCmdError "bad recovery phrase") pure $ importRecoveryKey (encodeUtf8 phrase) + 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 @@ -5587,8 +5583,8 @@ 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" $> APIWalletCreate, - "/_wallet import " *> (APIWalletImport <$> textP), + "/_wallet create new" $> APIWalletCreate Nothing, + "/_wallet create seed=" *> (APIWalletCreate . Just <$> textP), "/_wallet export " *> (APIWalletExportDerivedSecret <$> keyIndexP), "/_wallet export" $> APIWalletExportSeedMnemonic, "/_wallet delete" $> APIWalletDelete, diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index cf12eabcee..e84ed81eeb 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -75,18 +75,18 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice <## "no wallet key" alice ##> "/_wallet export" alice <## "bad chat command: no wallet key on this device" - alice ##> "/_wallet create" + 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 create" + alice ##> "/_wallet create new" alice <## "bad chat command: this device already has a wallet key" testWalletPersists :: HasCallStack => TestParams -> IO () testWalletPersists ps = do rows <- withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> "/_wallet create" + alice ##> "/_wallet create new" nameRows alice -- same database, new session: a name bought at that address must stay reachable withTestChat ps "alice" $ \alice -> do @@ -96,7 +96,7 @@ testWalletPersists ps = do testWalletSharedByProfiles :: HasCallStack => TestParams -> IO () testWalletSharedByProfiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice ##> ("/_wallet create seed=" <> B.unpack testPhrase) rows <- nameRows alice alice ##> "/create user alisa" showActiveUser alice "alisa" @@ -109,20 +109,20 @@ testWalletSharedByProfiles ps = withNewTestChat ps "alice" aliceProfile $ \alice testWalletImport :: HasCallStack => TestParams -> IO () testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice ##> ("/_wallet create seed=" <> 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 import " <> B.unpack testPhrase) + alice ##> ("/_wallet create seed=" <> 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 import " <> B.unpack (B.unwords $ replicate 12 "abandon")) + alice ##> ("/_wallet create seed=" <> B.unpack (B.unwords $ replicate 12 "abandon")) alice <## "bad chat command: bad recovery phrase" testWalletExportDerivedSecret :: HasCallStack => TestParams -> IO () testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice ##> ("/_wallet create seed=" <> B.unpack testPhrase) _ <- nameRows alice alice ##> "/_wallet export 1" alice <## "m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0 0x9a983cb3d832fbde5ab49d692b7a8bf5b5d232479c99333d0fc8e1d21f1b55b6" @@ -135,22 +135,22 @@ testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \al testWalletDelete :: HasCallStack => TestParams -> IO () testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice ##> ("/_wallet create seed=" <> B.unpack testPhrase) _ <- nameRows alice alice ##> "/_wallet delete" alice <## "no wallet key" - alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice ##> ("/_wallet create seed=" <> B.unpack testPhrase) _ <- nameRows alice pure () testWalletImportThenRestore :: HasCallStack => TestParams -> IO () testWalletImportThenRestore ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice ##> ("/_wallet create seed=" <> 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 import " <> B.unpack testPhrase) + alice ##> ("/_wallet create seed=" <> B.unpack testPhrase) rows <- nameRows alice map fst rows `shouldBe` ["m/44'/60'/0'/0/1", "m/44'/60'/0'/0/2"] From da20a039ae57d808de040104e8bcdb18b9b279cc Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 11 Sep 2026 14:06:26 +0000 Subject: [PATCH 30/67] core: the argument is a mnemonic, so name it one Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- docs/rfcs/2026-09-10-wallet-keys.md | 12 ++++++------ src/Simplex/Chat/Library/Commands.hs | 2 +- tests/WalletTests.hs | 18 +++++++++--------- 3 files changed, 16 insertions(+), 16 deletions(-) diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 123bfb568c..f2923dd2c7 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -66,12 +66,12 @@ at the profile level, while what a profile buys sits at the address level. Internal API. The names commands will call these; users will not. ``` -/_wallet the next name addresses -/_wallet create new generate the seed -/_wallet create seed= take the seed from a phrase -/_wallet export the seed mnemonic -/_wallet export one derived secret, as 0x and 64 hex digits -/_wallet delete delete the seed +/_wallet the next name addresses +/_wallet create new generate the seed +/_wallet create mnemonic= take the seed from a mnemonic +/_wallet export the seed mnemonic +/_wallet export one derived secret, as 0x and 64 hex digits +/_wallet delete delete the seed ``` Creating and importing are one command, because they differ only in where the diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index b336aca007..c601c3efd6 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -5584,7 +5584,7 @@ chatCommandP = "/_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 seed=" *> (APIWalletCreate . Just <$> textP), + "/_wallet create mnemonic=" *> (APIWalletCreate . Just <$> textP), "/_wallet export " *> (APIWalletExportDerivedSecret <$> keyIndexP), "/_wallet export" $> APIWalletExportSeedMnemonic, "/_wallet delete" $> APIWalletDelete, diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index e84ed81eeb..4afbb0bff9 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -96,7 +96,7 @@ testWalletPersists ps = do testWalletSharedByProfiles :: HasCallStack => TestParams -> IO () testWalletSharedByProfiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet create seed=" <> B.unpack testPhrase) + alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase) rows <- nameRows alice alice ##> "/create user alisa" showActiveUser alice "alisa" @@ -109,20 +109,20 @@ testWalletSharedByProfiles ps = withNewTestChat ps "alice" aliceProfile $ \alice testWalletImport :: HasCallStack => TestParams -> IO () testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet create seed=" <> B.unpack testPhrase) + 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 seed=" <> 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 seed=" <> B.unpack (B.unwords $ replicate 12 "abandon")) + alice ##> ("/_wallet create mnemonic=" <> B.unpack (B.unwords $ replicate 12 "abandon")) alice <## "bad chat command: bad recovery phrase" testWalletExportDerivedSecret :: HasCallStack => TestParams -> IO () testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet create seed=" <> B.unpack testPhrase) + alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase) _ <- nameRows alice alice ##> "/_wallet export 1" alice <## "m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0 0x9a983cb3d832fbde5ab49d692b7a8bf5b5d232479c99333d0fc8e1d21f1b55b6" @@ -135,22 +135,22 @@ testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \al testWalletDelete :: HasCallStack => TestParams -> IO () testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet create seed=" <> B.unpack testPhrase) + alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase) _ <- nameRows alice alice ##> "/_wallet delete" alice <## "no wallet key" - alice ##> ("/_wallet create seed=" <> B.unpack testPhrase) + 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 seed=" <> B.unpack testPhrase) + 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 seed=" <> B.unpack testPhrase) + 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"] From 899d5c3a4157e1d60ec2a9f19ea53dde85fbcd68 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 11 Sep 2026 14:19:31 +0000 Subject: [PATCH 31/67] core: the column holds entropy, so name it entropy The mnemonic comes back from the entropy exactly, which is what export returns, and the seed PBKDF2 derives from the mnemonic is computed when a key is needed and never stored. The column was the one thing called seed that is not the seed, which is why it needed a comment saying so. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- docs/rfcs/2026-09-10-wallet-keys.md | 9 +++++++-- .../Store/Postgres/Migrations/M20260908_wallet_seeds.hs | 2 +- .../Chat/Store/Postgres/Migrations/chat_schema.sql | 2 +- .../Store/SQLite/Migrations/M20260908_wallet_seeds.hs | 2 +- src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql | 2 +- src/Simplex/Chat/Store/Wallets.hs | 4 ++-- 6 files changed, 13 insertions(+), 8 deletions(-) diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index f2923dd2c7..ecf5215a2a 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -68,7 +68,7 @@ Internal API. The names commands will call these; users will not. ``` /_wallet the next name addresses /_wallet create new generate the seed -/_wallet create mnemonic= take the seed from a mnemonic +/_wallet create mnemonic= take the entropy from a mnemonic /_wallet export the seed mnemonic /_wallet export one derived secret, as 0x and 64 hex digits /_wallet delete delete the seed @@ -86,7 +86,7 @@ not leave the device, and the raw command would be logged there. ```sql CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, - seed BLOB NOT NULL, + entropy BLOB NOT NULL, next_name_index INTEGER NOT NULL DEFAULT 1, single_seed INTEGER NOT NULL DEFAULT 1 ); @@ -94,6 +94,11 @@ CREATE TABLE wallet_seeds ( No other table is touched. +What is stored is the BIP-39 entropy, 16 to 32 bytes. The mnemonic comes back +from it exactly, as the entropy and its checksum determine the words, and that +is what export returns. The seed itself, the 64 bytes PBKDF2 derives from the +mnemonic, is computed when a key is needed and never stored. + `next_name_index` is a high-water mark, not a count of names held. A name a device no longer tracks still owns its address, so an index is never reused. 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 d8617d0841..7dd970d8f6 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs @@ -11,7 +11,7 @@ m20260908_wallet_seeds = [r| CREATE TABLE wallet_seeds ( wallet_seed_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - seed BYTEA NOT NULL, + entropy BYTEA NOT NULL, -- see the SQLite migration next_name_index BIGINT NOT NULL DEFAULT 1, -- one seed per device for now diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index c5e5fa52e5..1cdf43e011 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1535,7 +1535,7 @@ ALTER TABLE test_chat_schema.users ALTER COLUMN user_id ADD GENERATED ALWAYS AS CREATE TABLE test_chat_schema.wallet_seeds ( wallet_seed_id bigint NOT NULL, - seed bytea NOT NULL, + entropy bytea NOT NULL, next_name_index bigint DEFAULT 1 NOT NULL, single_seed smallint DEFAULT 1 NOT NULL ); 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 a2fdc2b500..9f3b0468c9 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs @@ -10,7 +10,7 @@ m20260908_wallet_seeds = [sql| CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, - seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes + 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, diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 80c72a42e7..a3c86389db 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -854,7 +854,7 @@ CREATE TABLE rcv_roster_transfers( ) STRICT; CREATE TABLE wallet_seeds( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, - seed BLOB NOT NULL, -- BIP-39 entropy, 16-32 bytes + 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, diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index bd09feb3a0..a9a098b96c 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -28,7 +28,7 @@ toSeed (sId, seed) = WalletSeed {wsId = sId, wsEntropy = seed} getDeviceSeed :: DB.Connection -> IO (Maybe WalletSeed) getDeviceSeed db = maybeFirstRow toSeed $ - DB.query_ db "SELECT wallet_seed_id, seed FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1" + 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 @@ -43,7 +43,7 @@ createSeed :: DB.Connection -> ByteString -> IO Bool createSeed db entropy = getDeviceSeed db >>= \case Just _ -> pure False - Nothing -> True <$ DB.execute db "INSERT INTO wallet_seeds (seed) VALUES (?)" (Only $ DB.Binary entropy) + Nothing -> True <$ DB.execute db "INSERT INTO wallet_seeds (entropy) VALUES (?)" (Only $ DB.Binary entropy) deleteSeed :: DB.Connection -> SeedId -> IO () deleteSeed db sId = DB.execute db "DELETE FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) From a5730b6210a0d27f51b51ba0b3b5575920807e42 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 11 Sep 2026 15:19:34 +0000 Subject: [PATCH 32/67] docs: 24 words is a choice, not a default Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- docs/rfcs/2026-09-10-wallet-keys.md | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index ecf5215a2a..2c1c3c7e1e 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -67,7 +67,7 @@ Internal API. The names commands will call these; users will not. ``` /_wallet the next name addresses -/_wallet create new generate the seed +/_wallet create new generate the seed, 24 words /_wallet create mnemonic= take the entropy from a mnemonic /_wallet export the seed mnemonic /_wallet export one derived secret, as 0x and 64 hex digits @@ -81,6 +81,11 @@ created only when asked for, never at startup and never as a side effect of reading. None of these is forwarded to a remote host: the recovery phrase must not leave the device, and the raw command would be logged there. +The generated mnemonic is 24 words, 256 bits of entropy, rather than 12. That +buys nothing at the security level secp256k1 works at, and it is twice as much +to write down, so it is a deliberate choice rather than a default: entropy +cannot be added to a seed afterwards, and what a seed is used for can grow. + ## Schema ```sql From 87a0232d8eee8a2a2ce661ca2c1c0fa45c3025ea Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 11 Sep 2026 15:24:18 +0000 Subject: [PATCH 33/67] core: deleting the seed reports ok, not the empty wallet Delete re-ran /_wallet, so a successful delete printed "no wallet key", which is the same words the error uses and reads as a failure. There is nothing to show after a delete. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- src/Simplex/Chat/Library/Commands.hs | 2 +- tests/WalletTests.hs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index c601c3efd6..e62dd88f7d 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1516,7 +1516,7 @@ processChatCommand cxt nm = \case APIWalletDelete -> withUser $ \_ -> do seed <- deviceSeed withFastStore' $ \db -> deleteSeed db (wsId seed) - processChatCommand cxt nm APIWallet + ok_ APISendCallInvitation contactId callType -> withUser $ \user -> do -- party initiating call ct <- withFastStore $ \db -> getContact db cxt user contactId diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 4afbb0bff9..8e25344cba 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -138,7 +138,7 @@ testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase) _ <- nameRows alice alice ##> "/_wallet delete" - alice <## "no wallet key" + alice <## "ok" alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase) _ <- nameRows alice pure () From 8fcfd87b6b499713c00c10f050692e5a648858db Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 11 Sep 2026 15:33:54 +0000 Subject: [PATCH 34/67] docs: the export argument is an index, not a name Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- docs/rfcs/2026-09-10-wallet-keys.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 2c1c3c7e1e..c8f86df1b0 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -70,7 +70,7 @@ Internal API. The names commands will call these; users will not. /_wallet create new generate the seed, 24 words /_wallet create mnemonic= take the entropy from a mnemonic /_wallet export the seed mnemonic -/_wallet export one derived secret, as 0x and 64 hex digits +/_wallet export one name key's secret, 0x and 64 hex digits /_wallet delete delete the seed ``` From ba673f95bffc7345de327f14a76d4679d0411cbb Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 14 Sep 2026 08:09:04 +0000 Subject: [PATCH 35/67] core: export says which kind of secret, so other kinds can follow /_wallet export name rather than an index straight after export. The bare form stays the seed mnemonic, and a profile's key can be added beside the name one without the argument having to be guessed from its shape. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- bots/src/API/Docs/Commands.hs | 2 +- docs/rfcs/2026-09-10-wallet-keys.md | 2 +- src/Simplex/Chat/Controller.hs | 4 ++-- src/Simplex/Chat/Library/Commands.hs | 4 ++-- tests/WalletTests.hs | 6 +++--- 5 files changed, 9 insertions(+), 9 deletions(-) diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 8ac5f43238..a12f2bb34b 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -453,7 +453,7 @@ undocumentedCommands = "APIWallet", "APIWalletCreate", "APIWalletDelete", - "APIWalletExportDerivedSecret", + "APIWalletExportNameSecret", "APIWalletExportSeedMnemonic", "CheckChatRunning", "ConfirmRemoteCtrl", diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index c8f86df1b0..294fac0b75 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -70,7 +70,7 @@ Internal API. The names commands will call these; users will not. /_wallet create new generate the seed, 24 words /_wallet create mnemonic= take the entropy from a mnemonic /_wallet export the seed mnemonic -/_wallet export one name key's secret, 0x and 64 hex digits +/_wallet export name one name key's secret, 0x and 64 hex digits /_wallet delete delete the seed ``` diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 1adbfa3498..d28327b0d6 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -420,7 +420,7 @@ data ChatCommand | APIWallet | APIWalletCreate {recoveryPhrase :: Maybe Text} | APIWalletExportSeedMnemonic - | APIWalletExportDerivedSecret {nameIndex :: NameIndex} + | APIWalletExportNameSecret {nameIndex :: NameIndex} | APIWalletDelete | APISendCallInvitation ContactId CallType | SendCallInvitation ContactName CallType @@ -750,7 +750,7 @@ allowRemoteCommand = \case APIWallet -> False APIWalletCreate {} -> False APIWalletExportSeedMnemonic -> False - APIWalletExportDerivedSecret {} -> False + APIWalletExportNameSecret {} -> False APIWalletDelete -> False _ -> True diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index e62dd88f7d..630dd9a763 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1509,7 +1509,7 @@ processChatCommand cxt nm = \case seed <- deviceSeed phrase <- either throwCmdError pure $ recoveryKeyPhrase seed pure $ CRWalletSeedMnemonic user (safeDecodeUtf8 phrase) - APIWalletExportDerivedSecret nameIdx -> withUser $ \user -> do + 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) @@ -5585,7 +5585,7 @@ chatCommandP = "/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP), "/_wallet create new" $> APIWalletCreate Nothing, "/_wallet create mnemonic=" *> (APIWalletCreate . Just <$> textP), - "/_wallet export " *> (APIWalletExportDerivedSecret <$> keyIndexP), + "/_wallet export name " *> (APIWalletExportNameSecret <$> keyIndexP), "/_wallet export" $> APIWalletExportSeedMnemonic, "/_wallet delete" $> APIWalletDelete, "/_wallet" $> APIWallet, diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 8e25344cba..a2f5a899f3 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -124,13 +124,13 @@ testWalletExportDerivedSecret :: HasCallStack => TestParams -> IO () testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase) _ <- nameRows alice - alice ##> "/_wallet export 1" + 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 15" + 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 4294967296" + alice ##> "/_wallet export name 4294967296" alice <## "bad chat command: Failed reading: empty" testWalletDelete :: HasCallStack => TestParams -> IO () From 1b33002ee7e28e7913624ea11b18d4a9d4f76e28 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 14 Sep 2026 14:06:48 +0200 Subject: [PATCH 36/67] disencourage hidden profiles using names --- docs/rfcs/2026-09-10-wallet-keys.md | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 294fac0b75..215eae9ba6 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -132,10 +132,12 @@ signed record edit, not a rebinding of keys. ## Hidden profiles -Nothing about profiles is encoded in the derivation, so the wallet holds nothing -that would disclose a hidden profile. The seed is the device's: whoever unlocks -any profile can export the phrase and derive every name key, hidden profiles -included. A key per profile rather than per device is what would change that. +A hidden profile should not register a name yet. The seed is the device's, so +whoever unlocks any profile can derive every name key. Beyond that, a name is +written into the profile's own row and listed device-wide, and hidden profiles +are a view filter rather than encryption, so the binding is readable beside it. +Nothing in the wallet prevents it, and closing it is work in the profiles and in +the name record rather than here. ## Scope From 4255b46c04d74230f2c89ab5f23f6212f8123b32 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 14 Sep 2026 15:01:47 +0200 Subject: [PATCH 37/67] let remote use wallet, but filter logs to avoid leaking secrets --- docs/rfcs/2026-09-10-wallet-keys.md | 3 +-- src/Simplex/Chat/Controller.hs | 17 ++++++++++++----- src/Simplex/Chat/Remote.hs | 2 +- tests/WalletTests.hs | 8 ++++++++ 4 files changed, 22 insertions(+), 8 deletions(-) diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 215eae9ba6..28352ef5e8 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -78,8 +78,7 @@ Creating and importing are one command, because they differ only in where the entropy comes from, and either is refused if the device already has a seed. The source is always named, so no seed is generated by typing a prefix. A seed is created only when asked for, never at startup and never as a side effect of -reading. None of these is forwarded to a remote host: the recovery phrase must -not leave the device, and the raw command would be logged there. +reading. The generated mnemonic is 24 words, 256 bits of entropy, rather than 12. That buys nothing at the security level secp256k1 works at, and it is twice as much diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index d28327b0d6..ea54876b1a 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -43,7 +43,9 @@ 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) @@ -747,13 +749,18 @@ allowRemoteCommand = \case DeleteRemoteCtrl _ -> False ExecChatStoreSQL _ -> False ExecAgentStoreSQL _ -> False - APIWallet -> False - APIWalletCreate {} -> False - APIWalletExportSeedMnemonic -> False - APIWalletExportNameSecret {} -> False - APIWalletDelete -> 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 diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 991218367a..d1203493e5 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -552,7 +552,7 @@ liftRC = liftError (ChatErrorRemoteCtrl . RCEProtocolError) handleSend :: (ByteString -> Int -> CM' (Either ChatError ChatResponse)) -> Text -> Int -> CM' RemoteResponse handleSend execCC command retryNum = do - logDebug $ "Send: " <> tshow command + logDebug $ "Send: " <> tshow (redactedCommand 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/tests/WalletTests.hs b/tests/WalletTests.hs index a2f5a899f3..be07ec1476 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -11,8 +11,10 @@ import qualified Data.ByteString.Char8 as B import Data.Either (isLeft) import Data.List (nub) 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 @@ -44,6 +46,12 @@ walletDerivationTests = do 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" walletTests :: SpecWith TestParams walletTests = do From 805f68ca9403146a7d1bbe6fdb74712eef5b3a01 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 21 Sep 2026 08:05:05 +0200 Subject: [PATCH 38/67] change RFC wallet architecture to BIP-85 per profile and hardened paths for names --- docs/rfcs/2026-09-10-wallet-keys.md | 368 ++++++++++++++++++++++------ 1 file changed, 290 insertions(+), 78 deletions(-) diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 28352ef5e8..35e5a6a8e7 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -11,75 +11,171 @@ of these addresses. Buying is not implemented yet. The keys land first, so the key material can be reviewed on its own, before the names protocol, the registrar and signing. +A device holds several chat profiles, and they are meant to stay apart. The +layout below gives each profile a seed of its own that the one phrase still +recovers. + ## Design -One BIP-39 seed per device, and one key per name. +One BIP-39 master seed per device. It signs nothing. Every key sits under a +per-profile seed that BIP-85 derives from it. ``` -m/44'/60'/0'/0/0 unused -m/44'/60'/0'/0/k names, k >= 1, counted per device -m/44'/60'/i'/0/j a profile's own addresses, i >= 1 (later) -m/5564'/60'/i'/... a profile's stealth meta keys (later) +master seed, 24 words the only thing to back up +└── profile p m/83696968'/39'/0'/24'/p' BIP-85, its own 24-word mnemonic + ├── m/44'/60'/0'/0/0 the profile's own address (later) + ├── m/44'/60'/n'/0/0 names, n >= 1 + ├── m/5564'/60'/0'/0'/0 stealth spend (later) + └── m/5564'/60'/0'/1'/0 stealth view (later) ``` -This is plain BIP-44, so the addresses match wallets people already use: the -device's names are MetaMask's account list from its second entry on, and a -profile's first address is the matching Ledger Live account. The tests pin that -against the standard `abandon ... about` mnemonic: +BIP-85 derives entropy from a BIP-32 node: take the node's private key `k`, the +letter its specification uses, and read the leading bytes of +`HMAC-SHA512("bip-entropy-from-k", k)`. Application `39'` reads those bytes as +BIP-39 entropy, with the language, the word count and the index in the path, so +`0'` is English, `24'` is the word count and `p'` is the profile. Every index is +hardened, so the derivation never needs the public-key half of BIP-32, which is +the half simplexmq deliberately does not implement. + +BIP-85 lands in simplexmq as `Simplex.Messaging.Crypto.BIP85`, on top of the +BIP-32 and BIP-39 modules already there. It needs no new dependency: the +HMAC-SHA512 it uses is the one BIP-32 already uses. + +Below the profile seed this is plain BIP-44, with a name at the account level. +An exported profile mnemonic therefore reaches the same addresses in another +wallet: that profile's names are Ledger Live's account list from its second +entry on, which Ledger Live, Rabby and others enumerate. The tests pin BIP-85 +against the vectors in its own specification, and BIP-44 against the standard +`abandon ... about` mnemonic: ``` -m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0 MetaMask account 2 -m/44'/60'/0'/0/2 0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A MetaMask account 3 +m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 Ledger Live account 1 +m/44'/60'/1'/0/0 0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265 Ledger Live account 2 ``` -The mnemonic imports there as a recovery phrase, a single secret as a private -key. `m/44'/60'/0'/0/0` is left unused so that neither names nor profiles claim -the point where both dimensions start. +**Why a seed per profile.** What a user can hand over is otherwise all or +nothing: the device phrase hands over every profile, a name's raw secret hands +over one name, and there is nothing in between. A profile seed is the thing in +between, and it is a mnemonic, so it imports anywhere. -**Why a key per name.** A name's owner is public, so an address that owns -several names links them: whoever knows one of them can read its owner and find -the rest. A key per name leaves no such link. It also keeps an export narrow, as -a name's secret is a leaf, with no chain code, so handing it over hands over -that name only. +**Why one key per name.** A name's owner is public, so an address that owns +several names links them, and that is as true inside one profile as across two. +A key per name leaves no such link. It also keeps an export narrow: a name's +secret is a leaf with no chain code, and its account index is hardened, so +handing it over hands over that name only, whatever else the holder has. -**Why names are not derived per profile.** Which profile a name belongs to is -in the record the name resolves to, which the owner signs, not in the key. A -profile dimension in the path would carry a mapping nothing reads, and it would -have to be reconstructed after a restore, since a phrase does not say which -profile held which account. Counting names per device removes the question. +**Why hardened throughout.** BIP-85 uses hardened indexes only, so no extended +public key exists that could link two profiles, and a leaked profile mnemonic +derives no sibling. A name sits at the account level for the same reason, since +that is the level BIP-44 hardens: on a non-hardened index, an extended public +key above a name plus that name's private key yields the parent key and so every +other name in the profile, and a mnemonic exported to another wallet is exactly +how such an extended public key comes to exist. A profile is a compartment with +one way out, its own mnemonic, and a name inside it is another. -The cost is compartmentalisation that is not in use. Per-profile accounts would -confine an exposed account-level extended key to one profile's names. Nothing -derives or exports an extended key, and nothing is planned to, so the compartment -has no exit. +**Why 24-word children.** The same reason the master is 24: entropy cannot be +added to a seed afterwards, and what a seed is used for can grow. One length to +recognise rather than two. The word count is part of the path, so it is not a +setting and cannot change later. -Profiles get their own dimension for what is per profile: their own addresses at -account `i >= 1`, and stealth meta keys at purpose `5564'`. The plan there is one -meta address per profile, a spend key and a viewing key whose public halves are -published with the chat profile, opt-in. A sender derives a fresh destination -from it without a handshake, so one meta address serves any number of incoming -destinations, and those keys are not at a derivation path. That is why it belongs -at the profile level, while what a profile buys sits at the address level. +**Why names start at 1.** The account level under a profile seed counts that +profile's names and nothing else, so account 0 is free, and it is kept for the +profile's own address at `m/44'/60'/0'/0/0`. + +**What this does not buy.** A profile seed is derived from the master, so +whoever holds the master phrase derives every profile under it. This separates +what can be handed over, not what can be found. + +## Profiles + +A profile holds its seeds by index. Indexes come from a high-water mark and are +never reused: a profile the device no longer tracks still owns whatever its +names are registered to. + +A profile holds no index, one, or several. Several is what a recovery leaves: a +device restored from the phrase alone has one profile and no idea which of the +recovered indexes belonged to which profile before, so they all land on the +first one and are moved out from there. At most one of a profile's indexes is +the one it buys new names under, recorded rather than inferred, so taking on a +recovered index never silently moves where the next name goes. A recovered index +arrives with no such mark, because a scan hands a profile many at once and only +one of them can carry it. + +**A hidden profile cannot hold a name.** This is a check, not an omission. The +master derives every index under it, so unlocking any profile derives a hidden +profile's name keys as well, and the name is written into the profile's own row +and listed device-wide, while hidden profiles are a view filter rather than +encryption. Neither is closed by this layout, and closing them is work in the +profiles and in the name record rather than here. Until then a hidden profile is +given no index, by `bind` or by anything that follows it. + +An incognito profile holds no seed and cannot hold a name. ## Commands Internal API. The names commands will call these; users will not. ``` -/_wallet the next name addresses -/_wallet create new generate the seed, 24 words +/_wallet whether the device has a seed, and the + indexes the active profile holds +/_wallet create new generate the master seed, 24 words /_wallet create mnemonic= take the entropy from a mnemonic -/_wallet export the seed mnemonic -/_wallet export name one name key's secret, 0x and 64 hex digits -/_wallet delete delete the seed +/_wallet bind give the active profile the next free index to derive from master seed +/_wallet addresses profile=

from= count= + name addresses n onwards under profile p +/_wallet export master the master mnemonic +/_wallet export profile the active profile's mnemonic, 24 words +/_wallet export name one name key's secret, 0x and 64 hex digits +/_wallet delete delete the master seed and its indexes ``` Creating and importing are one command, because they differ only in where the -entropy comes from, and either is refused if the device already has a seed. The -source is always named, so no seed is generated by typing a prefix. A seed is -created only when asked for, never at startup and never as a side effect of +entropy comes from, and either is refused if the device already has a master. +The source is always named, so no seed is generated by typing a prefix. A seed +is created only when asked for, never at startup and never as a side effect of reading. +`bind` is how a profile gets the index it buys names under, and only a profile +that has none, so no profile is ever put on an index it did not ask for. A +profile can hold indexes and still have none to buy under, which is what a +recovery leaves, and `bind` serves it like any other. It is not done at startup +and not as a side effect of reading either. + +`bind` allocates from the high-water mark, and after a phrase is imported that +mark is 0 again, so a restored device can bind an index that already owns names. +Nothing is spent, because buying is not here, but the binding is written, and +the scan that raises the mark is what reconciles it. + +`/_wallet` answers what its name asks, whether the device has a seed and which +indexes the active profile holds, with the one it buys names under marked. It +derives no address, because an address is not what the bare command name +promises. + +`addresses` is that derivation, and it is what a name scan is built on. It +answers with a path and an address per row, and `from=` with `count=` windows +the walk the way `count=` already windows a list of chat items. It does not need +the index to exist in the database: a scan after a lost database is looking for +indexes no row was ever written for, so the command derives from the seed and +the numbers alone. + +There is one form and not a second one over profile indexes, because no single +address answers for a profile. Names are dense from 1, but a name can expire or +be transferred away, so the address at 1 can own nothing while 2 and 3 still do, +and a probe of 1 alone would skip a profile that holds names. The walk over +profile indexes is the walk over name indexes repeated, with the caller nesting +the same command. + +`export` names what it exports, for the same reason `create` names its source: +the three subjects hand over very different amounts, and none of them should be +printed by typing a prefix. The master mnemonic is every profile, a profile +mnemonic is that profile's names and nothing else, a name secret is one name, +and the responses say which is which. `export` on its own is refused. + +`export profile` and `export name` act on the index the active profile buys +under, and are refused where there is none, which is a profile that holds only +recovered indexes. Naming one of those is part of the command that moves a +recovered index to a profile, and lands with it. + The generated mnemonic is 24 words, 256 bits of entropy, rather than 12. That buys nothing at the security level secp256k1 works at, and it is twice as much to write down, so it is a deliberate choice rather than a default: entropy @@ -91,59 +187,175 @@ cannot be added to a seed afterwards, and what a seed is used for can grow. CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, entropy BLOB NOT NULL, - next_name_index INTEGER NOT NULL DEFAULT 1, - single_seed INTEGER NOT NULL DEFAULT 1 + seed_kind TEXT NOT NULL, -- 'master' or 'profile' + next_profile_index INTEGER NOT NULL DEFAULT 0 -- on the master row ); + +CREATE TABLE wallet_profile_indexes ( + wallet_profile_index_id INTEGER PRIMARY KEY AUTOINCREMENT, + wallet_seed_id INTEGER NOT NULL REFERENCES wallet_seeds ON DELETE CASCADE, + profile_index INTEGER, -- null on a seed of the profile's own + user_id INTEGER REFERENCES users ON DELETE SET NULL, + buys_names INTEGER NOT NULL DEFAULT 0, + next_name_index INTEGER NOT NULL DEFAULT 1 +); + +CREATE UNIQUE INDEX idx_wallet_seeds_master ON wallet_seeds(seed_kind) WHERE seed_kind = 'master'; +CREATE UNIQUE INDEX idx_wallet_profile_indexes_index ON wallet_profile_indexes(wallet_seed_id, profile_index); +CREATE UNIQUE INDEX idx_wallet_profile_indexes_buys ON wallet_profile_indexes(user_id) WHERE buys_names = 1; ``` -No other table is touched. +A seed is stored only when nothing derives it. The master is one such row, 16 to +32 bytes of BIP-39 entropy, and a profile seed derived from it is not stored at +all: it is computed when a key is needed, as the master seed itself already is. +`wallet_profile_indexes` holds what derivation cannot produce, which index a +profile was given, which seed it is under, which profile holds it, which of a +profile's indexes it buys names under, and how far each name counter has run. +The table is named for that, so that nothing in the schema reads as if a derived +seed were written down. -What is stored is the BIP-39 entropy, 16 to 32 bytes. The mnemonic comes back -from it exactly, as the entropy and its checksum determine the words, and that -is what export returns. The seed itself, the 64 bytes PBKDF2 derives from the -mnemonic, is computed when a key is needed and never stored. +**A seed the master does not cover.** A profile can be put on entropy of its +own, imported rather than derived, and then the master phrase does not recover +that profile and must not appear to. `seed_kind` is what says so: a `master` row +is the device seed, and the only row `next_profile_index` means anything on; a +`profile` row is entropy that arrived on its own and serves exactly one profile. +The partial unique index keeps one master and leaves the number of profile rows +open. On a profile row `profile_index` is null, because there is no BIP-85 step +to record, the seed being the profile's root already. Importing one is not in +this change. The column is here so that adding it later reads rows written now +correctly, rather than having to guess what an unflagged seed was. -`next_name_index` is a high-water mark, not a count of names held. A name a -device no longer tracks still owns its address, so an index is never reused. +`users` is not touched. Which profile holds an index is on the index row, +because a recovery gives a profile several and a column on `users` could only +say one. `buys_names` picks the one new names go under, and the partial unique +index makes at most one per profile the database's rule rather than the +caller's. `bind` sets it, and nothing else does: a recovered index arrives +unmarked, so a profile that holds only those buys under none until it binds one +or is given one. -The table models several seeds, which a later change needs. One per device is -`single_seed` and a unique index on it, lifted later by a `DROP INDEX` and a -`DROP COLUMN`. It is a named index rather than an inline `UNIQUE` because SQLite -cannot drop a `UNIQUE` column or its automatic index, which would force a table -rebuild. +Deleting a profile leaves its indexes behind, unassigned and with the flag +meaningless until one of them is given to a profile again, because the names +under them are still registered to their addresses. Deleting a seed takes the +index rows under that seed with it, because an index without the entropy it +counts against derives nothing. + +`next_name_index` counts what one seed holds, `next_profile_index` counts the +indexes handed out under the master, and both are high-water marks rather than +counts: an index is never reused. Migrations are sorted by name, and the runner requires the ones a database has applied to be a prefix of that list. A later wallet migration has to sort after `20260908_wallet_seeds`, or every existing database fails to start. +## Recovery + +Three cases, by how much of the database came back. + +**The database is current.** It carries `wallet_profile_indexes`, so every +profile is back on its own index and nothing is asked of the user. + +**The database is older than the seed.** It carries the profiles and the +bindings as of the backup, and nothing written after it: indexes bound since, +names bought since, and marks that are behind. What it is missing is the +phrase-only case over a smaller range, and the same scan answers it. + +**Only the phrase is left.** Nothing in it says which indexes were used, and +nothing says which profile held which one. A scan of owned names answers the +first question, and it has two levels, a profile index and a name index under +it, each with a gap limit, so the number of probes is their product. That is a +real cost on a scan that already hands one registrar a view of the derivation +tree at the moment a user is restoring a lost device. + +The scan is a nested walk over `addresses`, and the wallet supplies only the +addresses. For each profile index in turn the caller takes a window of that +profile's name addresses and asks the chain what they own, widening while names +keep appearing and moving on when a whole window owns nothing; the outer walk +ends after a gap of profile indexes that turned up nothing at all. Asking the +chain, counting the gap and raising the high-water marks are the names layer's, +which is why the wallet hands out addresses and counts nothing: a gap limit +needs the chain answers, and the wallet never sees them. + +What the scan finds lands on the first profile, unmarked, which then holds +several indexes and buys under none of them until it binds one. The user moves +them out one at a time, or keeps one where it is and makes it the one that +profile buys under. That is a command, and it lands with the scan rather than +here, because a scan is what puts a profile in that state and nothing else can. +What it will do is move one index, with every name under it, to the profile that +should have it: nothing signed and nothing +moved on chain, because ownership does not change, only which profile the app +shows the names under. Pointing a name at the new profile's address stays a +separate signed record edit, the same edit that points a name anywhere else. + +An index moves whole, because every name under it derives from one seed and an +index belongs to one profile at a time. Splitting one index's names between two +profiles would put a name under another profile's seed, which is an on-chain +transfer, and a name transfer is not a recovery step. + ## What the phrase does not carry -A phrase carries entropy and nothing else. Which indexes are already taken is -not in it, not derivable, and not on chain in a form the client can ask for -directly. `next_name_index` starts at 1 after an import, so until a scan of -owned names raises it, the next name would be bought at a path that already owns -one. Buying is not implemented here, so nothing can act on the stale mark yet, -and the scan lands with the registrar that makes buying possible. +A phrase carries entropy and nothing else. -Which profile a name belongs to is not lost with the device, because it was -never in the key. It is in the record, and re-pointing a name at a profile is a -signed record edit, not a rebinding of keys. +**Which indexes are taken.** `next_profile_index` starts at 0 after an import +and `next_name_index` at 1, so until a scan raises them the next profile or the +next name would be taken at a path that already owns one. `bind` allocates from +the first of those and ships here, so a restored device can write a binding on +an index that already owns names. Nothing is spent, because buying is not here, +and the scan that raises the marks is what reconciles the binding; until it +lands the marks are a known stale value rather than a count of what the seed +holds. -## Hidden profiles +**Which profile held which index.** Saying so by hand is the only answer, and +the command for it lands with the scan that finds the indexes in the first +place. Neither is here. -A hidden profile should not register a name yet. The seed is the device's, so -whoever unlocks any profile can derive every name key. Beyond that, a name is -written into the profile's own row and listed device-wide, and hidden profiles -are a view filter rather than encryption, so the binding is readable beside it. -Nothing in the wallet prevents it, and closing it is work in the profiles and in -the name record rather than here. +**A profile on a seed of its own.** Entropy that was imported rather than +derived is not in the phrase and cannot be, which is why `seed_kind` records it: +what the phrase recovers has to be answerable without guessing. ## Scope Not here: buying a name, the names protocol, the registrar, signing, the -recovery scan, several seeds per device, a profile's own addresses, stealth -addresses. +recovery scan and the command that moves a recovered index to a profile, +importing a seed for one profile and naming one in `addresses`, several master +seeds, the encrypted backup that would carry the index to profile map, a +profile's own address, stealth addresses. -`/_wallet` shows the next two name addresses, which is enough to check the -derivation against another wallet. It will show the names actually held once -those are recorded. +`delete` takes the master and the indexes under it. A seed of a profile's own is +a row that nothing derives, so it is not under the master and is not taken with +it, which is one more thing importing has to settle when it lands. + +Also not here: deriving at any layout but this one. A name may have been +registered from this phrase in another wallet, at the bare root or at a path +another tool picked, and finding those needs the caller to say which path rather +than which index. `addresses` takes indexes, so the candidate set of foreign +layouts arrives with the scan that needs it, as a widening of this command or +beside it. + +## What is verified + +Each of these is a test, not a claim. + +- The BIP-85 vectors for application `39'` reproduce from the specification's + master key, at 12, 18 and 24 words, entropy and mnemonic. +- A profile mnemonic, imported on its own as a master, reaches that profile's + name addresses, so a profile can be handed to another wallet. +- Two profiles' addresses do not intersect, and neither mnemonic derives the + other's. +- The BIP-44 addresses above reproduce, which is what an exported profile + mnemonic gives in a wallet that enumerates Ledger Live accounts. +- A name path hardens its account component, so the path a name renders at ends + in no index an extended public key could walk. +- `addresses` derives for an index the database has no row for, which is the + case a scan exists for. +- `/_wallet` derives nothing. +- A secret whose first byte is zero keeps its 64 hex digits. +- Nothing is created until asked for, and a second create or import is refused. +- `bind` gives an index only to a profile with none to buy under, and no two + profiles end up on one index. +- A profile buys names under at most one of its indexes, and the database + refuses a second. +- `export profile` is refused for a profile that has no index to buy under. +- `export` on its own is refused, and each subject exports only itself. +- An index at or above 2^31 is refused rather than folded onto a low one. +- Binding a hidden profile is refused. +- The keys and the addresses survive a restart. From b0185f2029303521e638d62259b8d0dd8b3dc7fe Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 21 Sep 2026 09:16:48 +0200 Subject: [PATCH 39/67] introduce keyring as the thing a profile binds to --- docs/rfcs/2026-09-10-wallet-keys.md | 721 +++++++++++++++++----------- 1 file changed, 440 insertions(+), 281 deletions(-) diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 35e5a6a8e7..6c24bae86c 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -2,360 +2,519 @@ ## Problem -The client needs keys of its own, outside the messaging protocol, at addresses -it can derive again after a restart, after a database restore, or on a new -device. Otherwise whatever an address holds is lost with the device. +The client needs blockchain keys of its own, separate from the messaging +protocol, at addresses it can derive again after a restart, after a database +restore, or on a new device. Otherwise whatever those addresses hold is lost +with the device. -The first use case is name ownership: a name bought in the app is owned by one -of these addresses. Buying is not implemented yet. The keys land first, so the -key material can be reviewed on its own, before the names protocol, the -registrar and signing. +The first use is names. A SimpleX name is a record on a public blockchain, the +record says which address owns the name, and anyone can read it. Every name gets +an account of its own, so the wallet has to hand out many accounts to one chat +profile. It is not a names wallet, though: an account is an ordinary Ethereum +account and can hold anything an address can hold, and later uses of this wallet +take accounts the same way. -A device holds several chat profiles, and they are meant to stay apart. The -layout below gives each profile a seed of its own that the one phrase still -recovers. +Buying a name is not implemented yet. The keys land first, so the key material +can be reviewed on its own, before the names protocol, the registrar and +signing. + +A device holds several chat profiles, and they are meant to stay apart. One +phrase that recovers the keys of all of them is one thing for the user to write +down, which is what a backup has to be. The layout below keeps the one phrase +and gives each profile a seed of its own to hand over instead, as a mnemonic. + +Those seeds are not derived from anything about a profile. A profile is bound to +one of them by a row in the database, and no path mentions a profile, so the two +can be rearranged without moving a key. + +## Words used here + +**Entropy** is the random bytes a wallet is built from. A **mnemonic**, or +**phrase**, is those bytes written as BIP-39 English words, and the two convert +into each other exactly. A **seed** is what BIP-39 computes from a mnemonic, and +it is what keys are derived from. Entropy is stored; a seed is computed when a +key is needed. + +A **path** such as `m/44'/60'/1'/0/0` walks down a BIP-32 tree. An apostrophe +marks a **hardened** step, which can only be taken with a private key, so no +public key above it can walk down to it. + +A **chat profile** is one of the identities the app lets a user keep side by +side. A **hidden profile** is one the app shows only after a passphrase is +typed. An **incognito profile** is a throwaway identity for a single contact. +The **active profile** is the one the app is currently showing. ## Design -One BIP-39 master seed per device. It signs nothing. Every key sits under a -per-profile seed that BIP-85 derives from it. +A device has one piece of BIP-39 entropy, the **master**, made as 24 words. +Nothing signs with it. Every key comes from it in two steps. + +The first step is BIP-85, which turns one node of a BIP-32 tree into fresh +BIP-39 entropy. The entropy at **keyring index** `r` is a **keyring**: its own +24 words, and every key under them. A keyring is what a chat profile is given, +but it is not derived from the profile. Which profile has which keyring is a row +in the database and nothing more. + +The second step is BIP-44 under a keyring. Each thing the keyring owns on chain +is given an **account index**, and the key at that index is the **account key**. +Names are the first use: one name, one account, and the account's address is +what the name record names as its owner. ``` master seed, 24 words the only thing to back up -└── profile p m/83696968'/39'/0'/24'/p' BIP-85, its own 24-word mnemonic - ├── m/44'/60'/0'/0/0 the profile's own address (later) - ├── m/44'/60'/n'/0/0 names, n >= 1 +└── keyring r m/83696968'/39'/0'/24'/r' BIP-85, its own 24-word mnemonic + ├── m/44'/60'/n'/0/0 accounts, n >= 0 ├── m/5564'/60'/0'/0'/0 stealth spend (later) └── m/5564'/60'/0'/1'/0 stealth view (later) ``` -BIP-85 derives entropy from a BIP-32 node: take the node's private key `k`, the -letter its specification uses, and read the leading bytes of -`HMAC-SHA512("bip-entropy-from-k", k)`. Application `39'` reads those bytes as -BIP-39 entropy, with the language, the word count and the index in the path, so -`0'` is English, `24'` is the word count and `p'` is the profile. Every index is -hardened, so the derivation never needs the public-key half of BIP-32, which is -the half simplexmq deliberately does not implement. +The two path shapes in that tree both start at `m` because they are two trees, +not one. BIP-85 ends the first and starts the second. -BIP-85 lands in simplexmq as `Simplex.Messaging.Crypto.BIP85`, on top of the -BIP-32 and BIP-39 modules already there. It needs no new dependency: the -HMAC-SHA512 it uses is the one BIP-32 already uses. +BIP-85 derives entropy from a BIP-32 node by taking the bare 32-byte private key +of that node and computing `HMAC-SHA512("bip-entropy-from-k", k)`, where `k` is +that private key. The letter is the specification's and appears in the string it +fixes; it is not the keyring index. For 24 words the entropy is the leading 32 +bytes of the result. The rest of the path says which mnemonic is wanted: `39'` +is BIP-85's BIP-39 application, `0'` is English, `24'` is the word count, and +`r'` is the keyring index. Every step of it is hardened. -Below the profile seed this is plain BIP-44, with a name at the account level. -An exported profile mnemonic therefore reaches the same addresses in another -wallet: that profile's names are Ledger Live's account list from its second -entry on, which Ledger Live, Rabby and others enumerate. The tests pin BIP-85 -against the vectors in its own specification, and BIP-44 against the standard -`abandon ... about` mnemonic: +Those 32 bytes are BIP-39 entropy, not a seed. They become 24 words, the words +become a seed the BIP-39 way, and that seed is the root of the profile's BIP-32 +tree. Feeding the 32 bytes straight into BIP-32 would work inside the app and +would silently break every claim below about another wallet. + +Under a keyring this is plain BIP-44, and an account index is BIP-44's own +account level, the level Ledger Live varies and also calls an account. So a +keyring phrase given to another wallet reaches the same addresses, and the two +tools use the word for the same thing, although Ledger Live numbers them from +one, where its Account 1 is index 0. + +That wallet will find account 0 by itself and stop there. BIP-44 says to stop +looking at the first account with no transaction history, and an account that +only owns a name never transacts, so discovery goes no further. Account 0 is +shown anyway, because a wallet always offers the first one. For any account +above it the user has to enter the path. + +The tests pin BIP-85 against the vectors in its own specification, and BIP-44 +against the standard `abandon ... about` test mnemonic, which is 12 words, with +an empty BIP-39 passphrase. Addresses are in EIP-55 mixed case. ``` -m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 Ledger Live account 1 -m/44'/60'/1'/0/0 0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265 Ledger Live account 2 +account 0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 +account 1 m/44'/60'/1'/0/0 0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265 ``` -**Why a seed per profile.** What a user can hand over is otherwise all or -nothing: the device phrase hands over every profile, a name's raw secret hands -over one name, and there is nothing in between. A profile seed is the thing in -between, and it is a mnemonic, so it imports anywhere. +**Why a seed for each profile.** Without one, what a user can hand over is all +or nothing. The master is every profile. A single account key is one account. +Neither is "this profile's accounts", and a keyring is, in a form another +wallet can take. -**Why one key per name.** A name's owner is public, so an address that owns -several names links them, and that is as true inside one profile as across two. -A key per name leaves no such link. It also keeps an export narrow: a name's -secret is a leaf with no chain code, and its account index is hardened, so -handing it over hands over that name only, whatever else the holder has. +**Why an account for each name.** A name's owner address is public, so an +address that owns two names links them. That is as true inside one profile as +across two, and an account for each name leaves no such link. The same holds for +anything else an account comes to own. -**Why hardened throughout.** BIP-85 uses hardened indexes only, so no extended -public key exists that could link two profiles, and a leaked profile mnemonic -derives no sibling. A name sits at the account level for the same reason, since -that is the level BIP-44 hardens: on a non-hardened index, an extended public -key above a name plus that name's private key yields the parent key and so every -other name in the profile, and a mnemonic exported to another wallet is exactly -how such an extended public key comes to exist. A profile is a compartment with -one way out, its own mnemonic, and a name inside it is another. +**Why the account level is hardened.** BIP-32 has a trap. For a step that is +not hardened, the extended public key above a key, together with that one key's +private half, yields the parent private key and from it every sibling. Giving a +keyring to another wallet is how such an extended public key comes to +exist, and giving away one account key is the other half. The two steps below an +account are not hardened, so those two halves do reach that account's own key, +and nothing else is derived under an account today. The account level itself is +hardened, so they stop there and reach no other account. BIP-85 is hardened at +every step, so they reach no other profile either. -**Why 24-word children.** The same reason the master is 24: entropy cannot be -added to a seed afterwards, and what a seed is used for can grow. One length to -recognise rather than two. The word count is part of the path, so it is not a -setting and cannot change later. +**Why accounts start at 0.** Nothing is reserved. Account 0 is an account like +any other, and it is the one address every wallet shows for a phrase without +being asked to look, so the first account a profile takes is the first account +another wallet offers. -**Why names start at 1.** The account level under a profile seed counts that -profile's names and nothing else, so account 0 is free, and it is kept for the -profile's own address at `m/44'/60'/0'/0/0`. +**Why 24 words.** Entropy cannot be added to a seed afterwards, and what a seed +is used for can grow. 256 bits buys nothing against secp256k1 and is twice as +much to write down, so it is a deliberate choice rather than a default. For a +keyring the word count is part of the BIP-85 path, so it is fixed for good. -**What this does not buy.** A profile seed is derived from the master, so -whoever holds the master phrase derives every profile under it. This separates -what can be handed over, not what can be found. +**What this does not buy.** A keyring is derived from the master, so +whoever holds the master phrase derives every profile. The design changes what a +user can hand over. It does not hide one profile from someone who already holds +the phrase. -## Profiles +## Profiles and keyrings -A profile holds its seeds by index. Indexes come from a high-water mark and are -never reused: a profile the device no longer tracks still owns whatever its -names are registered to. +A chat profile is bound to one keyring at a time, and a keyring to one profile. +The binding is a row, so it can be changed without any key moving, and nothing +about the profile reaches the derivation. -A profile holds no index, one, or several. Several is what a recovery leaves: a -device restored from the phrase alone has one profile and no idea which of the -recovered indexes belonged to which profile before, so they all land on the -first one and are moved out from there. At most one of a profile's indexes is -the one it buys new names under, recorded rather than inferred, so taking on a -recovered index never silently moves where the next name goes. A recovered index -arrives with no such mark, because a scan hands a profile many at once and only -one of them can carry it. +Most profiles have no keyring. One is bound on first use, when a profile buys a +name, rather than when the profile is made, so a user who never buys anything +has an empty wallet and a device that has never derived a key. -**A hidden profile cannot hold a name.** This is a check, not an omission. The -master derives every index under it, so unlocking any profile derives a hidden -profile's name keys as well, and the name is written into the profile's own row -and listed device-wide, while hidden profiles are a view filter rather than -encryption. Neither is closed by this layout, and closing them is work in the -profiles and in the name record rather than here. Until then a hidden profile is -given no index, by `bind` or by anything that follows it. +Keyring indexes are handed out in order and never reused, because a keyring the +device no longer tracks still owns whatever its accounts hold. A keyring can +sit unbound, holding accounts that no profile currently reaches. -An incognito profile holds no seed and cannot hold a name. +**A hidden profile binds no keyring, so it cannot own a name.** Two things would +leak. First, the master derives every keyring under it, so unlocking any profile +on the device also derives a hidden profile's account keys. Second, 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, and a binding is +something left behind, so an incognito profile binds none either. ## Commands -Internal API. The names commands will call these; users will not. +An internal API, called by the names commands and by whatever else takes +accounts later, rather than typed by users. Nothing here runs at startup or as a +side effect of reading, so no key material is made or handed out unless it was +asked for. ``` -/_wallet whether the device has a seed, and the - indexes the active profile holds -/_wallet create new generate the master seed, 24 words -/_wallet create mnemonic= take the entropy from a mnemonic -/_wallet bind give the active profile the next free index to derive from master seed -/_wallet addresses profile=

from= count= - name addresses n onwards under profile p -/_wallet export master the master mnemonic -/_wallet export profile the active profile's mnemonic, 24 words -/_wallet export name one name key's secret, 0x and 64 hex digits -/_wallet delete delete the master seed and its indexes +/_wallet whether the device has a master, and which + keyring the active profile is bound to, and + whether the master derives that keyring +/_wallet create new generate the master, 24 words +/_wallet create mnemonic= take the master entropy from a phrase +/_wallet bind keyring= bind the active profile to a keyring; without + keyring=, the lowest unbound one, or a new + one when there is none +/_wallet address keyring= account= + one address, with the keyring and account + index it came from; without account=, the + next free account; without keyring=, the one + the active profile is bound to +/_wallet export master the master phrase +/_wallet export keyring the bound keyring's phrase, 24 words +/_wallet export account one account key's secret, 0x and 64 hex +/_wallet delete delete the master entropy and its keyrings ``` -Creating and importing are one command, because they differ only in where the -entropy comes from, and either is refused if the device already has a master. -The source is always named, so no seed is generated by typing a prefix. A seed -is created only when asked for, never at startup and never as a side effect of -reading. +**create.** Generating and importing are one command, because they differ only +in where the entropy comes from. The source is always named, so no key material +is generated by typing a prefix. A second one is refused while the device has a +master. An imported phrase must be 12, 15, 18, 21 or 24 BIP-39 English words +with a valid checksum; case and spacing are normalised and anything else is +refused. There is no BIP-39 passphrase, because it would be a second secret to +back up and losing it would look exactly like losing the phrase. A phrase that +was used with one elsewhere therefore lands on a different tree here and finds +nothing, with nothing to say why. -`bind` is how a profile gets the index it buys names under, and only a profile -that has none, so no profile is ever put on an index it did not ask for. A -profile can hold indexes and still have none to buy under, which is what a -recovery leaves, and `bind` serves it like any other. It is not done at startup -and not as a side effect of reading either. +**bind.** The active profile is bound to a keyring. Named with `keyring=` it +takes that one, which is how a profile that was bound to the wrong one is put +right. Left to itself it takes the lowest keyring that exists and is unbound, +and only makes a new one, from a counter on the master, when there is none. That +order is what makes a recovery work without anyone naming anything. -`bind` allocates from the high-water mark, and after a phrase is imported that -mark is 0 again, so a restored device can bind an index that already owns names. -Nothing is spent, because buying is not here, but the binding is written, and -the scan that raises the mark is what reconciles it. +The counter is a high-water mark and not a count of what is held. A profile that +already has a keyring is refused, since a profile has one at a time; rebinding +it means naming the new one, which says plainly that the old one is being let +go. -`/_wallet` answers what its name asks, whether the device has a seed and which -indexes the active profile holds, with the one it buys names under marked. It -derives no address, because an address is not what the bare command name -promises. +BIP-32 hardens an index by adding 2^31 to it, so an index at or above 2^31 wraps +into one that is not hardened. That is a silent loss of hardening rather than a +collision, and it would put a whole profile back in reach of the attack the +account level stops. Every index this API takes is refused at or above 2^31, +both the keyring index and the account index. -`addresses` is that derivation, and it is what a name scan is built on. It -answers with a path and an address per row, and `from=` with `count=` windows -the walk the way `count=` already windows a list of chat items. It does not need -the index to exist in the database: a scan after a lost database is looking for -indexes no row was ever written for, so the command derives from the seed and -the numbers alone. +After an import the counter is unknown rather than zero, because the phrase does +not say how many keyrings it has already been used for. Making a new keyring is +refused while it is unknown, so an imported phrase cannot make one that already +has accounts in use. Binding an existing keyring is still allowed, which is the +whole of what a recovered device does. Only a search of the chain can set the +counter, and it is not in this change. -There is one form and not a second one over profile indexes, because no single -address answers for a profile. Names are dense from 1, but a name can expire or -be transferred away, so the address at 1 can own nothing while 2 and 3 still do, -and a probe of 1 alone would skip a profile that holds names. The walk over -profile indexes is the walk over name indexes repeated, with the caller nesting -the same command. +**address.** This is where derivation is asked for. It answers with one path and +one address, and with the keyring index and account index they came from, so a +caller that left either out knows what it got. `keyring=` left out means the +keyring the active profile is bound to, and is refused when it is bound to none. +`account=` left out means the next free account, which is what +the counter says and is a reading rather than a claim: the command takes nothing +and writes nothing, and asking twice gives the same answer. It is refused where +there is no counter to read, which is a keyring index with no row and an index +whose counter is still unknown. -`export` names what it exports, for the same reason `create` names its source: -the three subjects hand over very different amounts, and none of them should be -printed by typing a prefix. The master mnemonic is every profile, a profile -mnemonic is that profile's names and nothing else, a name secret is one name, -and the responses say which is which. `export` on its own is refused. +With `keyring=` given, the keyring index does not have to exist in the database. +A device that lost its database is looking for indexes no row was ever written +for, so the command works from the master entropy and the two numbers alone. -`export profile` and `export name` act on the index the active profile buys -under, and are refused where there is none, which is a profile that holds only -recovered indexes. Naming one of those is part of the command that moves a -recovered index to a profile, and lands with it. +One address at a time is all this needs to be. A caller sweeping the tree loops +over both numbers itself, and there is deliberately no form that sweeps keyring +indexes for it, because no single address answers for a keyring: what an account +holds can go away, so account 0 can hold nothing while 1 and 2 still hold +names. -The generated mnemonic is 24 words, 256 bits of entropy, rather than 12. That -buys nothing at the security level secp256k1 works at, and it is twice as much -to write down, so it is a deliberate choice rather than a default: entropy -cannot be added to a seed afterwards, and what a seed is used for can grow. +**export.** Each form names what it exports, for the same reason `create` names +its source. The three hand over very different amounts. The master phrase is +every keyring. A keyring phrase is every account under one keyring, and grows +as branches are added to the layout. An account key's secret is one account. All +three are deterministic, so each also hands over what has not been made yet: +accounts taken later, and keyrings made later. `export` with no argument is +refused. `export keyring` and `export account` act on the keyring the active +profile is bound to, and are refused for a profile bound to none. + +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, which is not in this change. Signing is not in this change either, and +when it lands it is a command here that signs and returns a signature, not +`export account` followed by signing elsewhere. A key that left for routine work +would make the narrow export the ordinary path and waste the point of having +one. + +**delete.** The master entropy and its index rows go. Names already registered +stay registered to their addresses, and only the phrase reaches them again, so a +user who has not written the phrase down loses them. Nothing else in the +database is touched. + +Every refusal says which it is: no master, a master already exists, a bad +phrase, a hidden profile, a profile that already has an index, a profile with no +index, an unknown counter, or an index out of range. + +## Recovery + +Three cases, by how much of the database came back. + +**Only the phrase is left.** A phrase carries entropy and nothing else. Two +things are not in it and cannot be. The first is which indexes were used: the +master's counter starts out unknown, as does the account counter of any index +found later, and only a search of the chain sets them. The second is which +profile was bound to which keyring: nothing recovers that, and the order the +user makes profiles in is what stands in for it. + +That search is a nested walk over `address`, one call for each candidate. For +each keyring index in turn, the caller asks for account 0, then 1, and so on, +and asks what each address owns. It keeps going while accounts turn out to hold +something and moves on after a run of accounts that hold nothing. The walk over +keyring indexes ends after a run of indexes that hold nothing at all. Asking +what an address owns, deciding how long a run has to be, and recording the +answers belong to the caller, because the wallet never sees an answer. + +Two things about that question are unsettled. Going from an address to the names +it owns is not something a registry answers on chain, so it takes the registrar +or an indexer, and which one decides who sees the search. And the answer covers +what is owned now, while the search is asked which indexes were ever used, so an +index whose names have all expired or moved on looks unused. The search can +therefore under-report, and a user who knows better has to be able to send it +further. + +Nothing outside this API writes these tables, so the search does not set the +counters itself. It reports what it found, and a command of this API that lands +with it writes a row for each keyring found, unbound, and moves the master's +counter past them. + +What the search finds keeps its own keyring index. Nothing is attached to a +profile, and the user is asked nothing. As the user makes chat profiles again +and each one buys its first name, `bind` takes the lowest unbound keyring, so +profiles made in the same order as before end up on the keyrings they had. That +is the whole of the normal case: the user does not have to remember which +keyring was which, only to make the profiles in the order they made them +before. + +A different order puts a profile on a keyring that was another one's, which +shows up as the wrong names. `bind keyring=` fixes it, and fixing it moves no +key and signs nothing, because ownership does not change, only which profile the +app shows the accounts under. Pointing a name at the new profile's address is a +separate signed edit of the name's record. + +A keyring binds whole. Every account under it comes from one seed, so splitting +its accounts between two profiles would mean putting an account under another +keyring. That is an on-chain transfer of whatever the account holds, not a +recovery step. + +**The database is older than the master.** It has the profiles and the indexes +as of the backup, and nothing written after it: indexes bound since, names +taken since, and counters that are behind. A counter that is behind is worse +than one that is unknown, because it looks usable, so such a database is treated +as having unknown counters until the same search fills the gap, over a smaller +range. + +**The database is current.** It records which profile is bound to which keyring, +so every profile is back on its own and nothing is asked of the user. ## Schema ```sql CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, - entropy BLOB NOT NULL, - seed_kind TEXT NOT NULL, -- 'master' or 'profile' - next_profile_index INTEGER NOT NULL DEFAULT 0 -- on the master row + entropy BLOB NOT NULL CHECK (length(entropy) IN (16, 20, 24, 28, 32)), + seed_kind TEXT NOT NULL CHECK (seed_kind IN ('master', 'keyring')), + next_keyring_index INTEGER -- master only; null means not known yet ); -CREATE TABLE wallet_profile_indexes ( - wallet_profile_index_id INTEGER PRIMARY KEY AUTOINCREMENT, +CREATE TABLE wallet_keyrings ( + wallet_keyring_id INTEGER PRIMARY KEY AUTOINCREMENT, wallet_seed_id INTEGER NOT NULL REFERENCES wallet_seeds ON DELETE CASCADE, - profile_index INTEGER, -- null on a seed of the profile's own + keyring_index INTEGER, -- null when the entropy was imported user_id INTEGER REFERENCES users ON DELETE SET NULL, - buys_names INTEGER NOT NULL DEFAULT 0, - next_name_index INTEGER NOT NULL DEFAULT 1 + next_account_index INTEGER -- from 0; null means not known yet ); -CREATE UNIQUE INDEX idx_wallet_seeds_master ON wallet_seeds(seed_kind) WHERE seed_kind = 'master'; -CREATE UNIQUE INDEX idx_wallet_profile_indexes_index ON wallet_profile_indexes(wallet_seed_id, profile_index); -CREATE UNIQUE INDEX idx_wallet_profile_indexes_buys ON wallet_profile_indexes(user_id) WHERE buys_names = 1; +CREATE UNIQUE INDEX idx_wallet_seeds_master + ON wallet_seeds(seed_kind) WHERE seed_kind = 'master'; +CREATE UNIQUE INDEX idx_wallet_keyrings_index + ON wallet_keyrings(wallet_seed_id, keyring_index); +CREATE UNIQUE INDEX idx_wallet_keyrings_imported + ON wallet_keyrings(wallet_seed_id) WHERE keyring_index IS NULL; +CREATE UNIQUE INDEX idx_wallet_keyrings_user + ON wallet_keyrings(user_id); ``` -A seed is stored only when nothing derives it. The master is one such row, 16 to -32 bytes of BIP-39 entropy, and a profile seed derived from it is not stored at -all: it is computed when a key is needed, as the master seed itself already is. -`wallet_profile_indexes` holds what derivation cannot produce, which index a -profile was given, which seed it is under, which profile holds it, which of a -profile's indexes it buys names under, and how far each name counter has run. -The table is named for that, so that nothing in the schema reads as if a derived -seed were written down. +Only entropy that nothing can derive is stored. The master is such a row, 16 to +32 bytes, which is 12 to 24 words: generation always makes 24, and a shorter +import is still accepted. A keyring's own entropy is never stored, because the +master entropy and a keyring index derive it whenever a key is needed. -**A seed the master does not cover.** A profile can be put on entropy of its -own, imported rather than derived, and then the master phrase does not recover -that profile and must not appear to. `seed_kind` is what says so: a `master` row -is the device seed, and the only row `next_profile_index` means anything on; a -`profile` row is entropy that arrived on its own and serves exactly one profile. -The partial unique index keeps one master and leaves the number of profile rows -open. On a profile row `profile_index` is null, because there is no BIP-85 step -to record, the seed being the profile's root already. Importing one is not in -this change. The column is here so that adding it later reads rows written now -correctly, rather than having to guess what an unflagged seed was. +So `wallet_keyrings` holds what derivation cannot produce: which keyring index +exists, which stored entropy it belongs to, which profile is bound to it, and +how far its account counter has run. A row is a keyring the device knows about, +and a row with no `user_id` is one no profile currently reaches. -`users` is not touched. Which profile holds an index is on the index row, -because a recovery gives a profile several and a column on `users` could only -say one. `buys_names` picks the one new names go under, and the partial unique -index makes at most one per profile the database's rule rather than the -caller's. `bind` sets it, and nothing else does: a recovered index arrives -unmarked, so a profile that holds only those buys under none until it binds one -or is given one. +`users` is not touched. The binding lives on the keyring row, and the unique +index on `user_id` is what makes one keyring for each profile a rule of the +database rather than of the caller. Null user ids are distinct, so any number of +keyrings can sit unbound. -Deleting a profile leaves its indexes behind, unassigned and with the flag -meaningless until one of them is given to a profile again, because the names -under them are still registered to their addresses. Deleting a seed takes the -index rows under that seed with it, because an index without the entropy it -counts against derives nothing. +Deleting a chat profile leaves its keyring row, unbound, because the accounts +under it still hold whatever they held. Deleting stored entropy takes the +keyring rows that belong to it, because a keyring index with no entropy behind +it derives nothing. -`next_name_index` counts what one seed holds, `next_profile_index` counts the -indexes handed out under the master, and both are high-water marks rather than -counts: an index is never reused. +The migration has no reverse step. Dropping `wallet_seeds` would destroy the +only copy of the master entropy, and with it everything the device owns. -Migrations are sorted by name, and the runner requires the ones a database has -applied to be a prefix of that list. A later wallet migration has to sort after -`20260908_wallet_seeds`, or every existing database fails to start. +**Entropy the master does not cover.** A keyring can be put on entropy of its +own, imported rather than derived. The master phrase then does not recover it, +and the schema must not suggest otherwise. `seed_kind` says which is which: a +`master` row is the device's own, and the only row `next_keyring_index` applies +to; a `keyring` row is entropy that arrived on its own, and its single keyring +row records a null `keyring_index`, there being no BIP-85 step to write down. +Importing is not implemented here. The column exists so that a row written now +can be read correctly later, rather than leaving an unmarked row to be guessed +at. -## Recovery +A null `keyring_index` means the row's entropy is the keyring's root, with no +BIP-85 step. A keyring row must therefore never combine a null `keyring_index` +with the master row, or `export keyring` would print the master phrase. No +constraint across two tables can say so in SQLite, so the rule is enforced where +rows are written and there is a test for it. -Three cases, by how much of the database came back. +## Known limits -**The database is current.** It carries `wallet_profile_indexes`, so every -profile is back on its own index and nothing is asked of the user. +These are accepted for this change, not oversights. -**The database is older than the seed.** It carries the profiles and the -bindings as of the backup, and nothing written after it: indexes bound since, -names bought since, and marks that are behind. What it is missing is the -phrase-only case over a smaller range, and the same scan answers it. - -**Only the phrase is left.** Nothing in it says which indexes were used, and -nothing says which profile held which one. A scan of owned names answers the -first question, and it has two levels, a profile index and a name index under -it, each with a gap limit, so the number of probes is their product. That is a -real cost on a scan that already hands one registrar a view of the derivation -tree at the moment a user is restoring a lost device. - -The scan is a nested walk over `addresses`, and the wallet supplies only the -addresses. For each profile index in turn the caller takes a window of that -profile's name addresses and asks the chain what they own, widening while names -keep appearing and moving on when a whole window owns nothing; the outer walk -ends after a gap of profile indexes that turned up nothing at all. Asking the -chain, counting the gap and raising the high-water marks are the names layer's, -which is why the wallet hands out addresses and counts nothing: a gap limit -needs the chain answers, and the wallet never sees them. - -What the scan finds lands on the first profile, unmarked, which then holds -several indexes and buys under none of them until it binds one. The user moves -them out one at a time, or keeps one where it is and makes it the one that -profile buys under. That is a command, and it lands with the scan rather than -here, because a scan is what puts a profile in that state and nothing else can. -What it will do is move one index, with every name under it, to the profile that -should have it: nothing signed and nothing -moved on chain, because ownership does not change, only which profile the app -shows the names under. Pointing a name at the new profile's address stays a -separate signed record edit, the same edit that points a name anywhere else. - -An index moves whole, because every name under it derives from one seed and an -index belongs to one profile at a time. Splitting one index's names between two -profiles would put a name under another profile's seed, which is an on-chain -transfer, and a name transfer is not a recovery step. - -## What the phrase does not carry - -A phrase carries entropy and nothing else. - -**Which indexes are taken.** `next_profile_index` starts at 0 after an import -and `next_name_index` at 1, so until a scan raises them the next profile or the -next name would be taken at a path that already owns one. `bind` allocates from -the first of those and ships here, so a restored device can write a binding on -an index that already owns names. Nothing is spent, because buying is not here, -and the scan that raises the marks is what reconciles the binding; until it -lands the marks are a known stale value rather than a count of what the seed -holds. - -**Which profile held which index.** Saying so by hand is the only answer, and -the command for it lands with the scan that finds the indexes in the first -place. Neither is here. - -**A profile on a seed of its own.** Entropy that was imported rather than -derived is not in the phrase and cannot be, which is why `seed_kind` records it: -what the phrase recovers has to be answerable without guessing. +1. **The phrase alone does not restore a device.** It recovers keys, not which + keyrings were used or which profile had which. Until the search lands, an + imported phrase gives a device its keys and no way to find what they own. +2. **The search tells one party a great deal.** It asks about every address the + phrase could hold a name on, in one burst, so it hands whoever answers a view + of the derivation tree and undoes much of what a key for each name buys. This + needs an answer before the search ships. +3. **The same phrase on two devices collides.** The counter lives in one + database, so two devices holding one phrase both make the same keyring and + each believes it is free. Sharing the counter needs a backup both can read. +4. **A profile hidden after it was bound keeps its keyring.** The check happens + at bind time, so it does not catch a profile that is bound first and hidden + afterwards. +5. **A deleted chat profile leaves its keyring unbound, and the next profile to + bind takes it.** So deleting a profile and making another hands the new one + the old one's accounts and names. That is what makes a recovery work without + anyone naming a keyring, and it is the wrong answer for a user who deleted a + profile to be rid of it. +6. **Nothing records which layout a seed was used under.** A phrase used in + another wallet may hold accounts at paths this document does not describe. +7. **An export answers over the link that asked.** These commands are not + blocked from a paired device, so `export master` can put the phrase on a + second device. Only the command text is kept out of logs; the answer is not. +8. **The database file is the phrase.** The stored entropy converts back into + the words, so anything that copies the database copies every key on the + device, including an archive exported to move devices. No export granularity + helps against someone who copies one file, and nothing here asks for a + passphrase or a confirmation before an export or a delete. `delete` does not + overwrite either, so a deleted row survives in free pages and in the journal, + and `delete` followed by `create mnemonic=` swaps the wallet for another one + with nothing recording that it happened. +9. **A wallet that publishes extended public keys links a profile's names.** + Giving a keyring phrase to a wallet that sends account keys to a vendor hands + that vendor every name in the profile at once, which is what a key for each + name otherwise prevents. +10. **`address` ignores profiles.** It derives for any index straight from + the master, so any caller can enumerate every profile's addresses, hidden + ones included. The search needs that, and nothing here narrows it. +11. **Keyring indexes are not dense.** A keyring can be made and never used, + and a run of empty keyrings is how the search decides to stop, so a keyring + far above a gap can be missed. +12. **A short import gives strong looking children.** A keyring phrase is always + 24 words, but it carries only what the master carries, so a 12-word master + yields 24-word keyring phrases with 128 bits behind them. +13. **Gas and discovery pull against each other.** If a name address ever pays + for anything, whatever funds it links every name in the profile on chain. + If it never pays, it never transacts, which is why no wallet finds it. +14. **Purpose `5564'` is ours.** It is reserved here for stealth keys, taken + from an ERC number rather than registered as a BIP-43 purpose, and nothing + in this change derives at it. ## Scope -Not here: buying a name, the names protocol, the registrar, signing, the -recovery scan and the command that moves a recovered index to a profile, -importing a seed for one profile and naming one in `addresses`, several master -seeds, the encrypted backup that would carry the index to profile map, a -profile's own address, stealth addresses. +Not here: buying a name, the names protocol, the registrar, signing, the search +of the chain and the command that records what it found, importing +entropy for one profile, more than one master, a shared backup of the counters, +and an address of the profile's own. -`delete` takes the master and the indexes under it. A seed of a profile's own is -a row that nothing derives, so it is not under the master and is not taken with -it, which is one more thing importing has to settle when it lands. +Not here either: deriving at any layout but this one. A name may have been +registered from this phrase in another wallet, at the bare master key or at a +path some other tool picked. Finding those means the caller says which path +rather than which index, and `address` takes indexes, so the set of layouts to +try arrives with the search that needs it. -Also not here: deriving at any layout but this one. A name may have been -registered from this phrase in another wallet, at the bare root or at a path -another tool picked, and finding those needs the caller to say which path rather -than which index. `addresses` takes indexes, so the candidate set of foreign -layouts arrives with the scan that needs it, as a widening of this command or -beside it. +BIP-85 is implemented in this project's crypto library, on top of the BIP-32 and +BIP-39 code already there, and adds no dependency, because the HMAC-SHA512 it +needs is the one BIP-32 already uses. + +The master entropy is a row in the chat database and is protected exactly as +that database is. A phrase reaches the API twice, in the text of +`create mnemonic=` and in the answer to `export`, and the command text is kept +out of logs. ## What is verified Each of these is a test, not a claim. -- The BIP-85 vectors for application `39'` reproduce from the specification's - master key, at 12, 18 and 24 words, entropy and mnemonic. -- A profile mnemonic, imported on its own as a master, reaches that profile's - name addresses, so a profile can be handed to another wallet. -- Two profiles' addresses do not intersect, and neither mnemonic derives the - other's. -- The BIP-44 addresses above reproduce, which is what an exported profile - mnemonic gives in a wallet that enumerates Ledger Live accounts. -- A name path hardens its account component, so the path a name renders at ends - in no index an extended public key could walk. -- `addresses` derives for an index the database has no row for, which is the - case a scan exists for. -- `/_wallet` derives nothing. -- A secret whose first byte is zero keeps its 64 hex digits. -- Nothing is created until asked for, and a second create or import is refused. -- `bind` gives an index only to a profile with none to buy under, and no two - profiles end up on one index. -- A profile buys names under at most one of its indexes, and the database - refuses a second. -- `export profile` is refused for a profile that has no index to buy under. -- `export` on its own is refused, and each subject exports only itself. -- An index at or above 2^31 is refused rather than folded onto a low one. -- Binding a hidden profile is refused. -- The keys and the addresses survive a restart. +- The BIP-85 vectors for application `39'` reproduce from the master key in its + specification, at 12, 18 and 24 words, entropy and mnemonic. +- The two addresses above reproduce from the `abandon ... about` mnemonic. +- A master phrase reaches an account address end to end, through BIP-85, through + a + real BIP-39 import of the words it produced, and on to `m/44'/60'/1'/0/0`, so + the BIP-39 step between the two trees cannot be skipped unnoticed. +- A keyring phrase, imported as an ordinary BIP-39 mnemonic, reaches that + profile's account addresses. +- Two profiles' first ten account addresses do not intersect, and neither + profile's phrase derives the other's. +- An account path hardens its account component. +- `address` derives for a keyring index the database has no row for, and with + no `account=` returns the counter's value rather than moving it, twice + running. +- `address` with no `keyring=` uses the active profile, and is refused when the + counter is unknown and no `account=` was given. +- `/_wallet` returns no address. +- An account key's secret whose first byte is zero keeps its 64 hex digits. +- A second generate or import is refused, and an imported phrase of a wrong + length, a wrong word or a bad checksum is refused. +- `bind` with no argument takes the lowest unbound keyring, and only makes a new + one when there is none. +- `bind` refuses to make a new keyring on an imported master, and still binds an + existing one. +- `bind` is refused on a hidden profile and on a profile already bound, and + `bind keyring=` rebinds it. +- Profiles made in the same order after a recovery end up on the keyrings they + had. +- Every index the API takes is refused at or above 2^31, both the keyring index + and the account index. +- No two profiles are bound to one keyring, and no profile to two. +- `export` with no argument is refused, and `export keyring` is refused for a + profile bound to none. +- `export keyring` never returns the master phrase, whatever a keyring row + says. +- Keys and addresses survive a restart. From 0dc0c963bf9a9b4dca0021f9afdba0fa12541c39 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 21 Sep 2026 09:55:21 +0200 Subject: [PATCH 40/67] restructure RFC --- docs/rfcs/2026-09-10-wallet-keys.md | 545 +++++++++------------------- 1 file changed, 163 insertions(+), 382 deletions(-) diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 6c24bae86c..83de0f0c06 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -2,181 +2,107 @@ ## Problem -The client needs blockchain keys of its own, separate from the messaging -protocol, at addresses it can derive again after a restart, after a database -restore, or on a new device. Otherwise whatever those addresses hold is lost -with the device. +The client has no static keys of its own which could be used for blockchain use cases. It needs them at addresses it can derive again after a restart, after a database restore, or on a new device, or whatever those addresses hold is lost with the device. -The first use is names. A SimpleX name is a record on a public blockchain, the -record says which address owns the name, and anyone can read it. Every name gets -an account of its own, so the wallet has to hand out many accounts to one chat -profile. It is not a names wallet, though: an account is an ordinary Ethereum -account and can hold anything an address can hold, and later uses of this wallet -take accounts the same way. +The first consumer is [public namespaces](./2026-05-21-public-namespaces.md): a name is a record on a public blockchain saying which address owns it, and every name gets an account of its own, so one chat profile needs many accounts. This is not a names wallet only, though. An account is an ordinary Ethereum account, and [stealth transfers](https://github.com/simplex-chat/simplex-chat/pull/7519) will take accounts the same way. -Buying a name is not implemented yet. The keys land first, so the key material -can be reviewed on its own, before the names protocol, the registrar and -signing. +A device also holds several chat profiles, which are meant to stay isolated, and the one phrase the user writes down has to recover all of them. -A device holds several chat profiles, and they are meant to stay apart. One -phrase that recovers the keys of all of them is one thing for the user to write -down, which is what a backup has to be. The layout below keeps the one phrase -and gives each profile a seed of its own to hand over instead, as a mnemonic. +Out of scope of this doc: buying a name, the names protocol, the registrar, signing, the scanning of the chain and the command that records what it finds, importing entropy for one keyring, more than one master, a backup of the counters shared between devices, and stealth keys. Buying is not implemented yet either, so the keys land first and can be reviewed on their own. -Those seeds are not derived from anything about a profile. A profile is bound to -one of them by a row in the database, and no path mentions a profile, so the two -can be rearranged without moving a key. +## Security objectives -## Words used here +1. Every key the device uses is derivable again from the master phrase alone. +2. Handing over one account key hands over that account, and nothing else, whatever else the holder has. +3. Handing over one keyring phrase hands over that keyring, and nothing else. +4. No extended public key links two keyrings, or two accounts within a keyring. +5. Key material is made and handed out only when asked for, never at startup and never as a side effect of reading. +6. A hidden profile owns nothing on chain, so nothing on chain is tied to it. -**Entropy** is the random bytes a wallet is built from. A **mnemonic**, or -**phrase**, is those bytes written as BIP-39 English words, and the two convert -into each other exactly. A **seed** is what BIP-39 computes from a mnemonic, and -it is what keys are derived from. Entropy is stored; a seed is computed when a -key is needed. - -A **path** such as `m/44'/60'/1'/0/0` walks down a BIP-32 tree. An apostrophe -marks a **hardened** step, which can only be taken with a private key, so no -public key above it can walk down to it. - -A **chat profile** is one of the identities the app lets a user keep side by -side. A **hidden profile** is one the app shows only after a passphrase is -typed. An **incognito profile** is a throwaway identity for a single contact. -The **active profile** is the one the app is currently showing. +Objectives 2 and 3 are what the derivation layout below is shaped by; 1, 5 and 6 are enforced by the commands; 4 is the reason every step that matters is hardened. ## Design -A device has one piece of BIP-39 entropy, the **master**, made as 24 words. -Nothing signs with it. Every key comes from it in two steps. +### Keyrings and accounts -The first step is BIP-85, which turns one node of a BIP-32 tree into fresh -BIP-39 entropy. The entropy at **keyring index** `r` is a **keyring**: its own -24 words, and every key under them. A keyring is what a chat profile is given, -but it is not derived from the profile. Which profile has which keyring is a row -in the database and nothing more. +A device has one piece of [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) entropy, the **master**, made as 24 words. Nothing signs with it. Every key comes from it in two steps. -The second step is BIP-44 under a keyring. Each thing the keyring owns on chain -is given an **account index**, and the key at that index is the **account key**. -Names are the first use: one name, one account, and the account's address is -what the name record names as its owner. +The first step is [BIP-85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki), which turns one node of a [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) tree into fresh BIP-39 entropy. The entropy at **keyring index** `r` is a **keyring**: its own 12 words, and every key under them. A keyring is what a chat profile is given, but it is not derived from the profile, and which profile has which keyring is a mapping in the database and nothing more. + +The second step is [BIP-44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki) under a keyring. Each thing the keyring owns on chain is given an **account index**, and the key at that index is the **account key**. Names are the first use: one name, one account, and the account's address is what the name record names as its owner. ``` master seed, 24 words the only thing to back up -└── keyring r m/83696968'/39'/0'/24'/r' BIP-85, its own 24-word mnemonic +└── keyring r m/83696968'/39'/0'/12'/r' BIP-85, its own 12-word mnemonic ├── m/44'/60'/n'/0/0 accounts, n >= 0 ├── m/5564'/60'/0'/0'/0 stealth spend (later) └── m/5564'/60'/0'/1'/0 stealth view (later) ``` -The two path shapes in that tree both start at `m` because they are two trees, -not one. BIP-85 ends the first and starts the second. +The two path shapes in that tree both start at `m` because they are two trees, not one. BIP-85 ends the first and starts the second. -BIP-85 derives entropy from a BIP-32 node by taking the bare 32-byte private key -of that node and computing `HMAC-SHA512("bip-entropy-from-k", k)`, where `k` is -that private key. The letter is the specification's and appears in the string it -fixes; it is not the keyring index. For 24 words the entropy is the leading 32 -bytes of the result. The rest of the path says which mnemonic is wanted: `39'` -is BIP-85's BIP-39 application, `0'` is English, `24'` is the word count, and -`r'` is the keyring index. Every step of it is hardened. +### Derivation -Those 32 bytes are BIP-39 entropy, not a seed. They become 24 words, the words -become a seed the BIP-39 way, and that seed is the root of the profile's BIP-32 -tree. Feeding the 32 bytes straight into BIP-32 would work inside the app and -would silently break every claim below about another wallet. +BIP-85 derives entropy from a BIP-32 node by taking the bare 32-byte private key of that node and computing `HMAC-SHA512("bip-entropy-from-k", k)`, where `k` is that private key. The letter is the specification's and appears inside the string it fixes; it is not the keyring index. For 12 words the entropy is the leading 16 bytes of the result. The rest of the path says which mnemonic is wanted: `39'` is BIP-85's BIP-39 application, `0'` is English, `12'` is the word count, and `r'` is the keyring index. Every step of it is hardened. -Under a keyring this is plain BIP-44, and an account index is BIP-44's own -account level, the level Ledger Live varies and also calls an account. So a -keyring phrase given to another wallet reaches the same addresses, and the two -tools use the word for the same thing, although Ledger Live numbers them from -one, where its Account 1 is index 0. +Those 16 bytes are BIP-39 entropy, not a seed. They become 12 words, the words become a seed the BIP-39 way, and that seed is the root of the keyring's BIP-32 tree. Feeding the 16 bytes straight into BIP-32 would work inside the app and would silently break every claim here about another wallet. -That wallet will find account 0 by itself and stop there. BIP-44 says to stop -looking at the first account with no transaction history, and an account that -only owns a name never transacts, so discovery goes no further. Account 0 is -shown anyway, because a wallet always offers the first one. For any account -above it the user has to enter the path. +Under a keyring this is plain BIP-44, and an account index is BIP-44's own account level, the level [Ledger Live](https://github.com/LedgerHQ/ledger-live-common/blob/HEAD/docs/derivation.md) varies and also calls an account. So a keyring phrase given to another wallet reaches the same addresses, and the two tools use the word for the same thing, although Ledger Live numbers them from one, where its Account 1 is index 0. -The tests pin BIP-85 against the vectors in its own specification, and BIP-44 -against the standard `abandon ... about` test mnemonic, which is 12 words, with -an empty BIP-39 passphrase. Addresses are in EIP-55 mixed case. +That wallet will find account 0 by itself and stop there. BIP-44 says to stop looking at the first account with no transaction history, and an account that only owns a name never transacts, so discovery goes no further. Account 0 is shown anyway, because a wallet always offers the first one. For any account above it the user has to enter the path. + +The tests pin BIP-85 against the vectors in its own specification, and BIP-44 against the standard `abandon ... about` test mnemonic, which is 12 words, with an empty BIP-39 passphrase. Addresses are in [EIP-55](https://eips.ethereum.org/EIPS/eip-55) mixed case. ``` +BIP-85, from the specification's own master key +m/83696968'/39'/0'/12'/0' 6250b68daf746d12a24d58b4787a714b + girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose + +BIP-44, from abandon ... about account 0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 account 1 m/44'/60'/1'/0/0 0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265 ``` -**Why a seed for each profile.** Without one, what a user can hand over is all -or nothing. The master is every profile. A single account key is one account. -Neither is "this profile's accounts", and a keyring is, in a form another -wallet can take. +### Why an account for each name -**Why an account for each name.** A name's owner address is public, so an -address that owns two names links them. That is as true inside one profile as -across two, and an account for each name leaves no such link. The same holds for -anything else an account comes to own. +The alternative is one account holding a profile's names. A name's owner address is public, so that address would link them, and that is as true inside one profile as across two. An account for each name leaves no such link, and the same holds for anything else an account comes to own. -**Why the account level is hardened.** BIP-32 has a trap. For a step that is -not hardened, the extended public key above a key, together with that one key's -private half, yields the parent private key and from it every sibling. Giving a -keyring to another wallet is how such an extended public key comes to -exist, and giving away one account key is the other half. The two steps below an -account are not hardened, so those two halves do reach that account's own key, -and nothing else is derived under an account today. The account level itself is -hardened, so they stop there and reach no other account. BIP-85 is hardened at -every step, so they reach no other profile either. +### Why the account level is hardened -**Why accounts start at 0.** Nothing is reserved. Account 0 is an account like -any other, and it is the one address every wallet shows for a phrase without -being asked to look, so the first account a profile takes is the first account -another wallet offers. +The alternative is BIP-44's ordinary address level, `m/44'/60'/0'/0/n`, which is what MetaMask enumerates and is therefore the friendlier path. It is not hardened, and BIP-32 has a trap there: the extended public key above a key, together with that one key's private half, yields the parent private key and from it every sibling. Giving a keyring phrase to another wallet is how such an extended public key comes to exist, and giving away one account key is the other half. -**Why 24 words.** Entropy cannot be added to a seed afterwards, and what a seed -is used for can grow. 256 bits buys nothing against secp256k1 and is twice as -much to write down, so it is a deliberate choice rather than a default. For a -keyring the word count is part of the BIP-85 path, so it is fixed for good. +The two steps below an account are not hardened, so those two halves do reach that account's own key, and nothing else is derived under an account today. The account level itself is hardened, so they stop there and reach no other account. BIP-85 is hardened at every step, so they reach no other keyring either. That is objectives 2, 3 and 4, and it is worth the loss of MetaMask's default path. -**What this does not buy.** A keyring is derived from the master, so -whoever holds the master phrase derives every profile. The design changes what a -user can hand over. It does not hide one profile from someone who already holds -the phrase. +### Why 24 words for the master and 12 for a keyring + +Entropy cannot be added to a seed afterwards and what a seed is used for can grow, so the master is 24 words, 256 bits, which is a deliberate choice rather than a default. + +A keyring is 12. It is the phrase a user actually writes down or types when handing one keyring to another wallet, and 12 words is half that work. 128 bits is the level secp256k1 works at, so nothing is lost at the point of use, although the master's extra 128 bits do not reach a keyring. The two lengths also tell the two phrases apart at a glance: 24 words is everything on the device, 12 words is one keyring. + +For a keyring the word count is part of the BIP-85 path, so it is fixed for good. ## Profiles and keyrings -A chat profile is bound to one keyring at a time, and a keyring to one profile. -The binding is a row, so it can be changed without any key moving, and nothing -about the profile reaches the derivation. +A chat profile is bound to one keyring at a time, and a keyring to one profile. The binding is a db mapping, so it can be changed without any key moving, and nothing about the profile reaches the derivation. -Most profiles have no keyring. One is bound on first use, when a profile buys a -name, rather than when the profile is made, so a user who never buys anything -has an empty wallet and a device that has never derived a key. +Profiles can bind no keyring. One is bound on first use, when a profile buys a name, rather than when the profile is made, so a user who never buys anything has an empty wallet and a device that has never derived a key. -Keyring indexes are handed out in order and never reused, because a keyring the -device no longer tracks still owns whatever its accounts hold. A keyring can -sit unbound, holding accounts that no profile currently reaches. +Keyring indexes are handed out in order and never reused, because a keyring the device no longer tracks still owns whatever its accounts hold. A keyring can sit unbound, holding accounts that no profile currently reaches. -**A hidden profile binds no keyring, so it cannot own a name.** Two things would -leak. First, the master derives every keyring under it, so unlocking any profile -on the device also derives a hidden profile's account keys. Second, 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. +A hidden profile binds no keyring, so it cannot own a name. Two things would leak. First, the master derives every keyring under it, so unlocking any profile on the device also derives a hidden profile's account keys. Second, 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, and a binding is -something left behind, so an incognito profile binds none either. +An incognito profile is meant to leave nothing behind, and a binding is something left behind, so an incognito profile binds none either. ## Commands -An internal API, called by the names commands and by whatever else takes -accounts later, rather than typed by users. Nothing here runs at startup or as a -side effect of reading, so no key material is made or handed out unless it was -asked for. +An internal API, called by the names commands and by whatever else takes accounts later, rather than typed by users. Nothing here runs at startup or as a side effect of reading. ``` /_wallet whether the device has a master, and which keyring the active profile is bound to, and whether the master derives that keyring /_wallet create new generate the master, 24 words -/_wallet create mnemonic= take the master entropy from a phrase +/_wallet create mnemonic= take the master entropy from a phrase, 24 words /_wallet bind keyring= bind the active profile to a keyring; without keyring=, the lowest unbound one, or a new one when there is none @@ -186,152 +112,97 @@ asked for. next free account; without keyring=, the one the active profile is bound to /_wallet export master the master phrase -/_wallet export keyring the bound keyring's phrase, 24 words +/_wallet export keyring the bound keyring's phrase, 12 words /_wallet export account one account key's secret, 0x and 64 hex /_wallet delete delete the master entropy and its keyrings ``` -**create.** Generating and importing are one command, because they differ only -in where the entropy comes from. The source is always named, so no key material -is generated by typing a prefix. A second one is refused while the device has a -master. An imported phrase must be 12, 15, 18, 21 or 24 BIP-39 English words -with a valid checksum; case and spacing are normalised and anything else is -refused. There is no BIP-39 passphrase, because it would be a second secret to -back up and losing it would look exactly like losing the phrase. A phrase that -was used with one elsewhere therefore lands on a different tree here and finds -nothing, with nothing to say why. +### `create` -**bind.** The active profile is bound to a keyring. Named with `keyring=` it -takes that one, which is how a profile that was bound to the wrong one is put -right. Left to itself it takes the lowest keyring that exists and is unbound, -and only makes a new one, from a counter on the master, when there is none. That -order is what makes a recovery work without anyone naming anything. +Generating and importing are one command, because they differ only in where the entropy comes from. The source is always named, so no key material is generated by typing a prefix, and a second one is refused while the device has a master. -The counter is a high-water mark and not a count of what is held. A profile that -already has a keyring is refused, since a profile has one at a time; rebinding -it means naming the new one, which says plainly that the old one is being let -go. +An imported phrase must be 24 BIP-39 English words with a valid checksum; case and spacing are normalised and anything else is refused. There is no BIP-39 passphrase, because it would be a second secret to back up and losing it would look exactly like losing the phrase. A phrase that was used with one elsewhere therefore lands on a different tree here and finds nothing, with nothing to say why. -BIP-32 hardens an index by adding 2^31 to it, so an index at or above 2^31 wraps -into one that is not hardened. That is a silent loss of hardening rather than a -collision, and it would put a whole profile back in reach of the attack the -account level stops. Every index this API takes is refused at or above 2^31, -both the keyring index and the account index. +### `bind` -After an import the counter is unknown rather than zero, because the phrase does -not say how many keyrings it has already been used for. Making a new keyring is -refused while it is unknown, so an imported phrase cannot make one that already -has accounts in use. Binding an existing keyring is still allowed, which is the -whole of what a recovered device does. Only a search of the chain can set the -counter, and it is not in this change. +The active profile is bound to a keyring. Named with `keyring=` it takes that one, which is how a profile bound to the wrong one is put right. Left to itself it takes the lowest keyring that exists and is unbound, and only makes a new one, from a counter on the master, when there is none. That order is what makes a recovery work without anyone naming anything. -**address.** This is where derivation is asked for. It answers with one path and -one address, and with the keyring index and account index they came from, so a -caller that left either out knows what it got. `keyring=` left out means the -keyring the active profile is bound to, and is refused when it is bound to none. -`account=` left out means the next free account, which is what -the counter says and is a reading rather than a claim: the command takes nothing -and writes nothing, and asking twice gives the same answer. It is refused where -there is no counter to read, which is a keyring index with no row and an index -whose counter is still unknown. +The counter is a high-water mark and not a count of what is held. A profile that already has a keyring is refused, since a profile has one at a time; rebinding means naming the new one, which says plainly that the old one is being let go. -With `keyring=` given, the keyring index does not have to exist in the database. -A device that lost its database is looking for indexes no row was ever written -for, so the command works from the master entropy and the two numbers alone. +After an import the counter is unknown rather than zero, because the phrase does not say how many keyrings it has already been used for. Making a new keyring is refused while it is unknown, so an imported phrase cannot make one that already has accounts in use. Binding an existing keyring is still allowed, which is the whole of what a recovered device does. -One address at a time is all this needs to be. A caller sweeping the tree loops -over both numbers itself, and there is deliberately no form that sweeps keyring -indexes for it, because no single address answers for a keyring: what an account -holds can go away, so account 0 can hold nothing while 1 and 2 still hold -names. +BIP-32 hardens an index by adding 2^31 to it, so an index at or above 2^31 wraps into one that is not hardened. That is a silent loss of hardening rather than a collision, and it would put a whole keyring back in reach of the attack the account level stops. Every index this API takes is refused at or above 2^31, both the keyring index and the account index. -**export.** Each form names what it exports, for the same reason `create` names -its source. The three hand over very different amounts. The master phrase is -every keyring. A keyring phrase is every account under one keyring, and grows -as branches are added to the layout. An account key's secret is one account. All -three are deterministic, so each also hands over what has not been made yet: -accounts taken later, and keyrings made later. `export` with no argument is -refused. `export keyring` and `export account` act on the keyring the active -profile is bound to, and are refused for a profile bound to none. +### `address` -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, which is not in this change. Signing is not in this change either, and -when it lands it is a command here that signs and returns a signature, not -`export account` followed by signing elsewhere. A key that left for routine work -would make the narrow export the ordinary path and waste the point of having -one. +This is where derivation is asked for. It answers with one path and one address, and with the indexes they came from, so a caller that left either out knows what it got. -**delete.** The master entropy and its index rows go. Names already registered -stay registered to their addresses, and only the phrase reaches them again, so a -user who has not written the phrase down loses them. Nothing else in the -database is touched. +```haskell +data WalletAddress = WalletAddress + { keyringIndex :: Word32, + accountIndex :: Word32, + keyPath :: Text, + address :: Text + } +``` -Every refusal says which it is: no master, a master already exists, a bad -phrase, a hidden profile, a profile that already has an index, a profile with no -index, an unknown counter, or an index out of range. +`keyring=` left out means the keyring the active profile is bound to, and is refused when it is bound to none. `account=` left out means the next free account, which is what the counter says: the counter is read, not moved, so asking twice gives the same answer. That form is refused where there is no counter to read, which is a keyring with no row and one whose counter is still unknown. + +With `keyring=` given, the keyring does not have to exist in the database. A device that lost its database is looking for keyrings no row was ever written for, so the command works from the master entropy and the two numbers alone. + +One address at a time is all this needs to be. A caller sweeping the tree loops over both numbers itself, and there is deliberately no form that sweeps keyring indexes for it, because no single address answers for a keyring: what an account holds can go away, so account 0 can hold nothing while 1 and 2 still hold names. + +### `export` + +Each form names what it exports, for the same reason `create` names its source. The three hand over very different amounts: the master phrase is every keyring, a keyring phrase is every account under one keyring, and an account key's secret is one account. All three are deterministic, so each also hands over what has not been made yet, accounts taken later and keyrings made later. `export` with no argument is refused, and `export keyring` and `export account` act on the keyring the active profile is bound to and are refused for a profile bound to none. + +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, which is not in this change. Signing is not in this change either, and when it lands it is a command here that signs and returns a signature, not `export account` followed by signing elsewhere. A key that left for routine work would make the narrow export the ordinary path and waste the point of having one. + +### `delete` + +The master entropy and its keyring rows go. Accounts already registered stay registered to their addresses, and only the phrase reaches them again, so a user who has not written the phrase down loses them. Nothing else in the database is touched. + +### Errors + +Every refusal says which it is. + +```haskell +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 + | WEProfileBound -- bind with no keyring=, on a profile that has one + | WEProfileUnbound -- export or address, on a profile that has none + | WECounterUnknown -- no counter to read yet, after an import + | WEIndexTooLarge -- at or above 2^31 +``` ## Recovery Three cases, by how much of the database came back. -**Only the phrase is left.** A phrase carries entropy and nothing else. Two -things are not in it and cannot be. The first is which indexes were used: the -master's counter starts out unknown, as does the account counter of any index -found later, and only a search of the chain sets them. The second is which -profile was bound to which keyring: nothing recovers that, and the order the -user makes profiles in is what stands in for it. +### Only the phrase is left -That search is a nested walk over `address`, one call for each candidate. For -each keyring index in turn, the caller asks for account 0, then 1, and so on, -and asks what each address owns. It keeps going while accounts turn out to hold -something and moves on after a run of accounts that hold nothing. The walk over -keyring indexes ends after a run of indexes that hold nothing at all. Asking -what an address owns, deciding how long a run has to be, and recording the -answers belong to the caller, because the wallet never sees an answer. +A phrase carries entropy and nothing else. Two things are not in it and cannot be. The first is which keyrings were used: the master's counter starts out unknown, as does the account counter of any keyring found later, and only a search of the chain sets them. The second is which profile was bound to which keyring: nothing recovers that, and the order the user makes profiles in is what stands in for it. -Two things about that question are unsettled. Going from an address to the names -it owns is not something a registry answers on chain, so it takes the registrar -or an indexer, and which one decides who sees the search. And the answer covers -what is owned now, while the search is asked which indexes were ever used, so an -index whose names have all expired or moved on looks unused. The search can -therefore under-report, and a user who knows better has to be able to send it -further. +That search is a nested walk over `address`, one call for each candidate. For each keyring index in turn, the caller asks for account 0, then 1, and so on, and asks what each address owns. It keeps going while accounts turn out to hold something and moves on after a run of accounts that hold nothing. The walk over keyring indexes ends after a run of indexes that hold nothing at all. Asking what an address owns, deciding how long a run has to be, and recording the answers belong to the caller, because the wallet never sees an answer. -Nothing outside this API writes these tables, so the search does not set the -counters itself. It reports what it found, and a command of this API that lands -with it writes a row for each keyring found, unbound, and moves the master's -counter past them. +Nothing outside this API writes these tables, so the search does not set the counters itself. It reports what it found, and a command of this API that lands with it writes a row for each keyring found, unbound, and moves the master's counter past them. -What the search finds keeps its own keyring index. Nothing is attached to a -profile, and the user is asked nothing. As the user makes chat profiles again -and each one buys its first name, `bind` takes the lowest unbound keyring, so -profiles made in the same order as before end up on the keyrings they had. That -is the whole of the normal case: the user does not have to remember which -keyring was which, only to make the profiles in the order they made them -before. +What the search finds keeps its own keyring index. Nothing is attached to a profile and the user is asked nothing. As the user makes chat profiles again and each one buys its first name, `bind` takes the lowest unbound keyring, so profiles made in the same order as before end up on the keyrings they had. That is the whole of the normal case: the user does not have to remember which keyring was which, only to make the profiles in the order they made them before. -A different order puts a profile on a keyring that was another one's, which -shows up as the wrong names. `bind keyring=` fixes it, and fixing it moves no -key and signs nothing, because ownership does not change, only which profile the -app shows the accounts under. Pointing a name at the new profile's address is a -separate signed edit of the name's record. +A different order puts a profile on a keyring that was another one's, which shows up as the wrong names. `bind keyring=` fixes it, and fixing it moves no key and signs nothing, because ownership does not change, only which profile the app shows the accounts under. Pointing a name at the new profile's address is a separate signed edit of the name's record. -A keyring binds whole. Every account under it comes from one seed, so splitting -its accounts between two profiles would mean putting an account under another -keyring. That is an on-chain transfer of whatever the account holds, not a -recovery step. +A keyring binds whole. Every account under it comes from one seed, so splitting its accounts between two profiles would mean putting an account under another keyring, which is an on-chain transfer of whatever the account holds and not a recovery step. -**The database is older than the master.** It has the profiles and the indexes -as of the backup, and nothing written after it: indexes bound since, names -taken since, and counters that are behind. A counter that is behind is worse -than one that is unknown, because it looks usable, so such a database is treated -as having unknown counters until the same search fills the gap, over a smaller -range. +### The database is older than the master -**The database is current.** It records which profile is bound to which keyring, -so every profile is back on its own and nothing is asked of the user. +It has the profiles and the keyrings as of the backup, and nothing written after it: keyrings bound since, accounts taken since, and counters that are behind. A counter that is behind is worse than one that is unknown, because it looks usable, so such a database is treated as having unknown counters until the same search fills the gap, over a smaller range. + +### The database is current + +It records which profile is bound to which keyring, so every profile is back on its own and nothing is asked of the user. ## Schema @@ -361,160 +232,70 @@ CREATE UNIQUE INDEX idx_wallet_keyrings_user ON wallet_keyrings(user_id); ``` -Only entropy that nothing can derive is stored. The master is such a row, 16 to -32 bytes, which is 12 to 24 words: generation always makes 24, and a shorter -import is still accepted. A keyring's own entropy is never stored, because the -master entropy and a keyring index derive it whenever a key is needed. +Only entropy that nothing can derive is stored. The master is such a row, 16 to 32 bytes, which is 12 to 24 words: generation always makes 24, and a shorter import is still accepted. A keyring's own entropy is never stored, because the master entropy and a keyring index derive it whenever a key is needed. -So `wallet_keyrings` holds what derivation cannot produce: which keyring index -exists, which stored entropy it belongs to, which profile is bound to it, and -how far its account counter has run. A row is a keyring the device knows about, -and a row with no `user_id` is one no profile currently reaches. +So `wallet_keyrings` holds what derivation cannot produce: which keyring index exists, which stored entropy it belongs to, which profile is bound to it, and how far its account counter has run. A row is a keyring the device knows about, and a row with no `user_id` is one no profile currently reaches. -`users` is not touched. The binding lives on the keyring row, and the unique -index on `user_id` is what makes one keyring for each profile a rule of the -database rather than of the caller. Null user ids are distinct, so any number of -keyrings can sit unbound. +`users` is not touched. The binding lives on the keyring row, and the unique index on `user_id` is what makes one keyring for each profile a rule of the database rather than of the caller. Null user ids are distinct, so any number of keyrings can sit unbound. -Deleting a chat profile leaves its keyring row, unbound, because the accounts -under it still hold whatever they held. Deleting stored entropy takes the -keyring rows that belong to it, because a keyring index with no entropy behind -it derives nothing. +Deleting a chat profile leaves its keyring row, unbound, because the accounts under it still hold whatever they held. Deleting stored entropy takes the keyring rows that belong to it, because a keyring index with no entropy behind it derives nothing. -The migration has no reverse step. Dropping `wallet_seeds` would destroy the -only copy of the master entropy, and with it everything the device owns. +The migration has no reverse step. Dropping `wallet_seeds` would destroy the only copy of the master entropy, and with it everything the device owns. -**Entropy the master does not cover.** A keyring can be put on entropy of its -own, imported rather than derived. The master phrase then does not recover it, -and the schema must not suggest otherwise. `seed_kind` says which is which: a -`master` row is the device's own, and the only row `next_keyring_index` applies -to; a `keyring` row is entropy that arrived on its own, and its single keyring -row records a null `keyring_index`, there being no BIP-85 step to write down. -Importing is not implemented here. The column exists so that a row written now -can be read correctly later, rather than leaving an unmarked row to be guessed -at. +A keyring can be put on entropy of its own, imported rather than derived. The master phrase then does not recover it, and the schema must not suggest otherwise. `seed_kind` says which is which: a `master` row is the device's own, and the only row `next_keyring_index` applies to; a `keyring` row is entropy that arrived on its own, and its single keyring row records a null `keyring_index`, there being no BIP-85 step to write down. Importing is not implemented here; the column exists so that a row written now can be read correctly later, rather than leaving an unmarked row to be guessed at. -A null `keyring_index` means the row's entropy is the keyring's root, with no -BIP-85 step. A keyring row must therefore never combine a null `keyring_index` -with the master row, or `export keyring` would print the master phrase. No -constraint across two tables can say so in SQLite, so the rule is enforced where -rows are written and there is a test for it. +A null `keyring_index` means the row's entropy is the keyring's root, with no BIP-85 step. A keyring row must therefore never combine a null `keyring_index` with the master row, or `export keyring` would print the master phrase. No constraint across two tables can say so in SQLite, so the rule is enforced where rows are written and there is a test for it. + +## Threat model + +**Someone with the database file.** Can: convert the stored entropy back into the master phrase, and from it derive every keyring and every account, present and future. Cannot: be shut out by anything in this design. The stored entropy is the phrase in another encoding, so an archive exported to move devices carries every key on the device, and no export granularity helps against a file copy. + +**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, or the keyring above it, or another keyring. + +**Someone with one keyring phrase.** Can: every account under that keyring, including accounts taken later. Cannot: reach another keyring, or the master. + +**A wallet a keyring phrase is imported into.** Can: compute and publish that keyring's account extended public keys, which is what enumerating BIP-44 accounts does, and some wallets send them to a vendor. That hands the vendor every account in the keyring at once, which is what an account for each name otherwise prevents. Cannot: reach another keyring. + +**Whoever answers the recovery search.** Can: see every address the phrase could hold a name on, in one burst, so it links every keyring on the device and recognises addresses that hold nothing yet, which is where future accounts will be. Cannot: derive a key. This is the sharpest cost in the design and it needs an answer before the search ships. + +**A paired device.** Can: run any of these commands, including `export master`, because they are not blocked from one. Only the command text is kept out of logs; the answer is not. ## Known limits -These are accepted for this change, not oversights. +1. **The phrase alone does not restore a device.** It recovers keys, not which keyrings were used or which profile had which. Until the search lands, an imported phrase gives a device its keys and no way to find what they own. +2. **The same phrase on two devices collides.** The counter lives in one database, so two devices holding one phrase both make the same keyring and each believes it is free. +3. **A profile hidden after it was bound keeps its keyring.** The check happens at bind time, so it does not catch a profile bound first and hidden afterwards. +4. **A deleted chat profile leaves its keyring unbound, and the next profile to bind takes it.** So deleting a profile and making another hands the new one the old one's accounts and names. That is what makes a recovery work without anyone naming a keyring, and it is the wrong answer for a user who deleted a profile to be rid of it. +5. **`address` ignores profiles.** It derives for any index straight from the master, so any caller can enumerate every keyring's addresses, hidden profiles' included. The search needs that, and nothing here narrows it. +6. **Keyring indexes are not dense.** A keyring can be made and never used, and a run of empty keyrings is how the search decides to stop, so a keyring far above a gap can be missed. +7. **Nothing records which layout a seed was used under.** A phrase used in another wallet may hold accounts at paths this doc does not describe. +8. **Gas and discovery pull against each other.** If an account ever pays for anything, whatever funds it links every account in the keyring on chain. If it never pays, it never transacts, which is why no wallet finds it past account 0. +9. **`delete` does not overwrite.** A deleted row survives in free pages and in the journal, and `delete` followed by `create mnemonic=` swaps the wallet for another one with nothing recording that it happened. +10. **Purpose `5564'` is ours.** It is reserved for stealth keys, taken from an [ERC-5564](https://eips.ethereum.org/EIPS/eip-5564) number rather than registered as a BIP-43 purpose, and nothing in this change derives at it. -1. **The phrase alone does not restore a device.** It recovers keys, not which - keyrings were used or which profile had which. Until the search lands, an - imported phrase gives a device its keys and no way to find what they own. -2. **The search tells one party a great deal.** It asks about every address the - phrase could hold a name on, in one burst, so it hands whoever answers a view - of the derivation tree and undoes much of what a key for each name buys. This - needs an answer before the search ships. -3. **The same phrase on two devices collides.** The counter lives in one - database, so two devices holding one phrase both make the same keyring and - each believes it is free. Sharing the counter needs a backup both can read. -4. **A profile hidden after it was bound keeps its keyring.** The check happens - at bind time, so it does not catch a profile that is bound first and hidden - afterwards. -5. **A deleted chat profile leaves its keyring unbound, and the next profile to - bind takes it.** So deleting a profile and making another hands the new one - the old one's accounts and names. That is what makes a recovery work without - anyone naming a keyring, and it is the wrong answer for a user who deleted a - profile to be rid of it. -6. **Nothing records which layout a seed was used under.** A phrase used in - another wallet may hold accounts at paths this document does not describe. -7. **An export answers over the link that asked.** These commands are not - blocked from a paired device, so `export master` can put the phrase on a - second device. Only the command text is kept out of logs; the answer is not. -8. **The database file is the phrase.** The stored entropy converts back into - the words, so anything that copies the database copies every key on the - device, including an archive exported to move devices. No export granularity - helps against someone who copies one file, and nothing here asks for a - passphrase or a confirmation before an export or a delete. `delete` does not - overwrite either, so a deleted row survives in free pages and in the journal, - and `delete` followed by `create mnemonic=` swaps the wallet for another one - with nothing recording that it happened. -9. **A wallet that publishes extended public keys links a profile's names.** - Giving a keyring phrase to a wallet that sends account keys to a vendor hands - that vendor every name in the profile at once, which is what a key for each - name otherwise prevents. -10. **`address` ignores profiles.** It derives for any index straight from - the master, so any caller can enumerate every profile's addresses, hidden - ones included. The search needs that, and nothing here narrows it. -11. **Keyring indexes are not dense.** A keyring can be made and never used, - and a run of empty keyrings is how the search decides to stop, so a keyring - far above a gap can be missed. -12. **A short import gives strong looking children.** A keyring phrase is always - 24 words, but it carries only what the master carries, so a 12-word master - yields 24-word keyring phrases with 128 bits behind them. -13. **Gas and discovery pull against each other.** If a name address ever pays - for anything, whatever funds it links every name in the profile on chain. - If it never pays, it never transacts, which is why no wallet finds it. -14. **Purpose `5564'` is ours.** It is reserved here for stealth keys, taken - from an ERC number rather than registered as a BIP-43 purpose, and nothing - in this change derives at it. +## Open questions -## Scope +1. Going from an address to the names it owns is not something a registry answers on chain, so the search needs the registrar or an indexer. Which one decides who sees the search, and that is the whole of the privacy cost in the threat model. +2. How long a run of empty accounts, and of empty keyrings, ends a walk. Both are guesses until the search is written, and a user who knows better has to be able to send it further. +3. Whether a keyring freed by deleting a chat profile should be handed to the next profile that binds, as limit 4 has it, or marked so that only keyrings the search found are handed out. The second costs one column. +4. Whether two devices holding one phrase should share the counter, which needs a backup both can read. -Not here: buying a name, the names protocol, the registrar, signing, the search -of the chain and the command that records what it found, importing -entropy for one profile, more than one master, a shared backup of the counters, -and an address of the profile's own. +## Files -Not here either: deriving at any layout but this one. A name may have been -registered from this phrase in another wallet, at the bare master key or at a -path some other tool picked. Finding those means the caller says which path -rather than which index, and `address` takes indexes, so the set of layouts to -try arrives with the search that needs it. - -BIP-85 is implemented in this project's crypto library, on top of the BIP-32 and -BIP-39 code already there, and adds no dependency, because the HMAC-SHA512 it -needs is the one BIP-32 already uses. - -The master entropy is a row in the chat database and is protected exactly as -that database is. A phrase reaches the API twice, in the text of -`create mnemonic=` and in the answer to `export`, and the command text is kept -out of logs. +- `src/Simplex/Chat/Wallet.hs`, derivation, pure. +- `src/Simplex/Chat/Store/Wallets.hs`, the two tables. +- `src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs` and the Postgres twin. +- `tests/WalletTests.hs`. +- BIP-85 lands in simplexmq as `Simplex.Messaging.Crypto.BIP85`, beside `BIP32` and `BIP39`, and adds no dependency, because the HMAC-SHA512 it needs is the one BIP-32 already uses. ## What is verified -Each of these is a test, not a claim. +**File:** `tests/WalletTests.hs`. Each of these is a test, not a claim. -- The BIP-85 vectors for application `39'` reproduce from the master key in its - specification, at 12, 18 and 24 words, entropy and mnemonic. -- The two addresses above reproduce from the `abandon ... about` mnemonic. -- A master phrase reaches an account address end to end, through BIP-85, through - a - real BIP-39 import of the words it produced, and on to `m/44'/60'/1'/0/0`, so - the BIP-39 step between the two trees cannot be skipped unnoticed. -- A keyring phrase, imported as an ordinary BIP-39 mnemonic, reaches that - profile's account addresses. -- Two profiles' first ten account addresses do not intersect, and neither - profile's phrase derives the other's. -- An account path hardens its account component. -- `address` derives for a keyring index the database has no row for, and with - no `account=` returns the counter's value rather than moving it, twice - running. -- `address` with no `keyring=` uses the active profile, and is refused when the - counter is unknown and no `account=` was given. -- `/_wallet` returns no address. -- An account key's secret whose first byte is zero keeps its 64 hex digits. -- A second generate or import is refused, and an imported phrase of a wrong - length, a wrong word or a bad checksum is refused. -- `bind` with no argument takes the lowest unbound keyring, and only makes a new - one when there is none. -- `bind` refuses to make a new keyring on an imported master, and still binds an - existing one. -- `bind` is refused on a hidden profile and on a profile already bound, and - `bind keyring=` rebinds it. -- Profiles made in the same order after a recovery end up on the keyrings they - had. -- Every index the API takes is refused at or above 2^31, both the keyring index - and the account index. -- No two profiles are bound to one keyring, and no profile to two. -- `export` with no argument is refused, and `export keyring` is refused for a - profile bound to none. -- `export keyring` never returns the master phrase, whatever a keyring row - says. -- Keys and addresses survive a restart. +1. **Vectors.** The BIP-85 vectors for application `39'` reproduce from the master key in its specification, at 12, 18 and 24 words, entropy and mnemonic. The two BIP-44 addresses above reproduce from `abandon ... about`. +2. **Interop.** A master phrase reaches an account address end to end, through BIP-85, through a real BIP-39 import of the 12 words it produced, and on to `m/44'/60'/1'/0/0`, so the BIP-39 step between the two trees cannot be skipped unnoticed. A keyring phrase imported on its own as an ordinary mnemonic reaches the same account addresses. +3. **Isolation.** Two keyrings' first ten account addresses do not intersect, neither keyring's phrase derives the other's, and an account path hardens its account component. +4. **Refusals.** A second generate or import; an imported phrase of a wrong length, a wrong word or a bad checksum; `bind` on a hidden profile, on a profile already bound, and making a new keyring on an imported master; `export` with no argument; `export keyring` for a profile bound to none; every index at or above 2^31. +5. **Binding order.** `bind` with no argument takes the lowest unbound keyring and only makes a new one when there is none, `bind keyring=` rebinds, profiles made in the same order after a recovery end up on the keyrings they had, and no two profiles are bound to one keyring nor one profile to two. +6. **Reads do not write.** `/_wallet` returns no address, `address` with no `account=` returns the counter's value twice running without moving it, and `address` derives for a keyring the database has no row for. +7. **Encoding and persistence.** An account key's secret whose first byte is zero keeps its 64 hex digits; `export keyring` never returns the master phrase whatever a keyring row says; keys and addresses survive a restart. From 59b5a6182adb32a8cd70febbce4be38d46d72716 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 21 Sep 2026 11:13:34 +0200 Subject: [PATCH 41/67] shorten --- docs/rfcs/2026-09-10-wallet-keys.md | 177 +++++++--------------------- 1 file changed, 41 insertions(+), 136 deletions(-) diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 83de0f0c06..68af0fa422 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -4,32 +4,26 @@ The client has no static keys of its own which could be used for blockchain use cases. It needs them at addresses it can derive again after a restart, after a database restore, or on a new device, or whatever those addresses hold is lost with the device. -The first consumer is [public namespaces](./2026-05-21-public-namespaces.md): a name is a record on a public blockchain saying which address owns it, and every name gets an account of its own, so one chat profile needs many accounts. This is not a names wallet only, though. An account is an ordinary Ethereum account, and [stealth transfers](https://github.com/simplex-chat/simplex-chat/pull/7519) will take accounts the same way. - -A device also holds several chat profiles, which are meant to stay isolated, and the one phrase the user writes down has to recover all of them. +The first consumer is [public namespaces](./2026-05-21-public-namespaces.md): a name is a record on a public blockchain saying which address owns it, and every name gets an account of its own, so one chat profile needs many accounts. This is not a names wallet only, though. An account is an ordinary Ethereum account, and [stealth transfers](https://github.com/simplex-chat/simplex-chat/pull/7519) will take accounts the same way. A device also holds several chat profiles, which are meant to stay isolated, and the one phrase the user writes down has to recover all of them. Out of scope of this doc: buying a name, the names protocol, the registrar, signing, the scanning of the chain and the command that records what it finds, importing entropy for one keyring, more than one master, a backup of the counters shared between devices, and stealth keys. Buying is not implemented yet either, so the keys land first and can be reviewed on their own. ## Security objectives 1. Every key the device uses is derivable again from the master phrase alone. -2. Handing over one account key hands over that account, and nothing else, whatever else the holder has. -3. Handing over one keyring phrase hands over that keyring, and nothing else. +2. Handing over one account key hands over that account and nothing else, whatever else the holder has. +3. Handing over one keyring phrase hands over that keyring and nothing else. 4. No extended public key links two keyrings, or two accounts within a keyring. 5. Key material is made and handed out only when asked for, never at startup and never as a side effect of reading. 6. A hidden profile owns nothing on chain, so nothing on chain is tied to it. -Objectives 2 and 3 are what the derivation layout below is shaped by; 1, 5 and 6 are enforced by the commands; 4 is the reason every step that matters is hardened. - ## Design ### Keyrings and accounts A device has one piece of [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) entropy, the **master**, made as 24 words. Nothing signs with it. Every key comes from it in two steps. -The first step is [BIP-85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki), which turns one node of a [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) tree into fresh BIP-39 entropy. The entropy at **keyring index** `r` is a **keyring**: its own 12 words, and every key under them. A keyring is what a chat profile is given, but it is not derived from the profile, and which profile has which keyring is a mapping in the database and nothing more. - -The second step is [BIP-44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki) under a keyring. Each thing the keyring owns on chain is given an **account index**, and the key at that index is the **account key**. Names are the first use: one name, one account, and the account's address is what the name record names as its owner. +First, [BIP-85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki) turns one node of a [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) tree into fresh BIP-39 entropy. The entropy at **keyring index** `r` is a **keyring**: its own 12 words, and every key under them. A keyring is what a chat profile is given, but it is not derived from the profile, and which profile has which keyring is a mapping in the database and nothing more. Second, [BIP-44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki) under a keyring gives each thing it owns on chain an **account index**, and the key there is the **account key**. Names are the first use: one name, one account, and that account's address is what the name record names as its owner. ``` master seed, 24 words the only thing to back up @@ -39,17 +33,13 @@ master seed, 24 words the only thing to back up └── m/5564'/60'/0'/1'/0 stealth view (later) ``` -The two path shapes in that tree both start at `m` because they are two trees, not one. BIP-85 ends the first and starts the second. - ### Derivation -BIP-85 derives entropy from a BIP-32 node by taking the bare 32-byte private key of that node and computing `HMAC-SHA512("bip-entropy-from-k", k)`, where `k` is that private key. The letter is the specification's and appears inside the string it fixes; it is not the keyring index. For 12 words the entropy is the leading 16 bytes of the result. The rest of the path says which mnemonic is wanted: `39'` is BIP-85's BIP-39 application, `0'` is English, `12'` is the word count, and `r'` is the keyring index. Every step of it is hardened. +BIP-85 derives entropy from a BIP-32 node by taking the bare 32-byte private key of that node and computing `HMAC-SHA512("bip-entropy-from-k", k)`, where `k` is that private key; the letter is the specification's, appears inside the string it fixes, and is not the keyring index. For 12 words the entropy is the leading 16 bytes. In the path, `39'` is BIP-85's BIP-39 application, `0'` is English, `12'` is the word count, `r'` is the keyring index, and every step is hardened. -Those 16 bytes are BIP-39 entropy, not a seed. They become 12 words, the words become a seed the BIP-39 way, and that seed is the root of the keyring's BIP-32 tree. Feeding the 16 bytes straight into BIP-32 would work inside the app and would silently break every claim here about another wallet. +Those 16 bytes are BIP-39 entropy, not a seed: they become 12 words, the words become a seed the BIP-39 way, and that seed is the root of a second BIP-32 tree, which is why both path shapes above start at `m`. Feeding the 16 bytes straight into BIP-32 would work inside the app and would silently break every claim here about another wallet. -Under a keyring this is plain BIP-44, and an account index is BIP-44's own account level, the level [Ledger Live](https://github.com/LedgerHQ/ledger-live-common/blob/HEAD/docs/derivation.md) varies and also calls an account. So a keyring phrase given to another wallet reaches the same addresses, and the two tools use the word for the same thing, although Ledger Live numbers them from one, where its Account 1 is index 0. - -That wallet will find account 0 by itself and stop there. BIP-44 says to stop looking at the first account with no transaction history, and an account that only owns a name never transacts, so discovery goes no further. Account 0 is shown anyway, because a wallet always offers the first one. For any account above it the user has to enter the path. +An account index is BIP-44's own account level, the level [Ledger Live](https://github.com/LedgerHQ/ledger-live-common/blob/HEAD/docs/derivation.md) varies and also calls an account, numbering from one where its Account 1 is index 0. A keyring phrase given to another wallet reaches the same addresses, but that wallet finds account 0 and stops, because BIP-44 says to stop at the first account with no transaction history and an account that only owns a name never transacts. Above it the user has to enter the path. The tests pin BIP-85 against the vectors in its own specification, and BIP-44 against the standard `abandon ... about` test mnemonic, which is 12 words, with an empty BIP-39 passphrase. Addresses are in [EIP-55](https://eips.ethereum.org/EIPS/eip-55) mixed case. @@ -69,29 +59,17 @@ The alternative is one account holding a profile's names. A name's owner address ### Why the account level is hardened -The alternative is BIP-44's ordinary address level, `m/44'/60'/0'/0/n`, which is what MetaMask enumerates and is therefore the friendlier path. It is not hardened, and BIP-32 has a trap there: the extended public key above a key, together with that one key's private half, yields the parent private key and from it every sibling. Giving a keyring phrase to another wallet is how such an extended public key comes to exist, and giving away one account key is the other half. - -The two steps below an account are not hardened, so those two halves do reach that account's own key, and nothing else is derived under an account today. The account level itself is hardened, so they stop there and reach no other account. BIP-85 is hardened at every step, so they reach no other keyring either. That is objectives 2, 3 and 4, and it is worth the loss of MetaMask's default path. +The alternative is BIP-44's ordinary address level, `m/44'/60'/0'/0/n`, which is what MetaMask enumerates and is therefore the friendlier path. It is not hardened, and BIP-32 has a trap there: the extended public key above a key, together with that one key's private half, yields the parent private key and from it every sibling. Giving a keyring phrase to another wallet is how such an extended public key comes to exist, and giving away one account key is the other half. The two steps below an account are not hardened, so those halves do reach that account's own key, but nothing else is derived under an account and the account level itself is hardened, so they stop there; BIP-85 is hardened throughout, so they reach no other keyring either. That is objectives 2, 3 and 4, and it is worth the loss of MetaMask's default path. ### Why 24 words for the master and 12 for a keyring -Entropy cannot be added to a seed afterwards and what a seed is used for can grow, so the master is 24 words, 256 bits, which is a deliberate choice rather than a default. - -A keyring is 12. It is the phrase a user actually writes down or types when handing one keyring to another wallet, and 12 words is half that work. 128 bits is the level secp256k1 works at, so nothing is lost at the point of use, although the master's extra 128 bits do not reach a keyring. The two lengths also tell the two phrases apart at a glance: 24 words is everything on the device, 12 words is one keyring. - -For a keyring the word count is part of the BIP-85 path, so it is fixed for good. +Entropy cannot be added to a seed afterwards and what a seed is used for can grow, so the master is 24 words. A keyring is 12, because it is the phrase a user writes down when handing one keyring to another wallet, and 128 bits is the level secp256k1 works at, so nothing is lost at the point of use. The two lengths also tell the phrases apart at a glance: 24 words is everything on the device, 12 words is one keyring. For a keyring the count is part of the BIP-85 path, so it is fixed for good. ## Profiles and keyrings -A chat profile is bound to one keyring at a time, and a keyring to one profile. The binding is a db mapping, so it can be changed without any key moving, and nothing about the profile reaches the derivation. +A chat profile is bound to one keyring at a time, and a keyring to one profile. The binding is a db mapping, so it can be changed without any key moving, and nothing about the profile reaches the derivation. Profiles can bind no keyring: one is bound 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. Keyring indexes are handed out in order and never reused, because a keyring the device no longer tracks still owns whatever its accounts hold, and a keyring can sit unbound. -Profiles can bind no keyring. One is bound on first use, when a profile buys a name, rather than when the profile is made, so a user who never buys anything has an empty wallet and a device that has never derived a key. - -Keyring indexes are handed out in order and never reused, because a keyring the device no longer tracks still owns whatever its accounts hold. A keyring can sit unbound, holding accounts that no profile currently reaches. - -A hidden profile binds no keyring, so it cannot own a name. Two things would leak. First, the master derives every keyring under it, so unlocking any profile on the device also derives a hidden profile's account keys. Second, 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, and a binding is something left behind, so an incognito profile binds none either. +A hidden profile binds no keyring, so it cannot own a name. Two things would leak: the master derives every keyring under it, 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 binds none either. ## Commands @@ -117,25 +95,13 @@ An internal API, called by the names commands and by whatever else takes account /_wallet delete delete the master entropy and its keyrings ``` -### `create` +`create` always names its source, so no key material is made by typing a prefix. An imported phrase must be 24 BIP-39 English words with a valid checksum, normalised for case and spacing. There is no BIP-39 passphrase, because it would be a second secret to back up and losing it would look exactly like losing the phrase, so a phrase used with one elsewhere lands on a different tree here and finds nothing. -Generating and importing are one command, because they differ only in where the entropy comes from. The source is always named, so no key material is generated by typing a prefix, and a second one is refused while the device has a master. +`bind` without an argument takes the lowest unbound keyring and only makes a new one when there is none, which is what makes a recovery work without anyone naming anything. Naming one is required to rebind, which says plainly that the old keyring is being let go. After an import the master's counter is unknown rather than zero, because the phrase does not say how many keyrings it has been used for, so making a keyring is refused until a search sets it, while binding an existing one 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. -An imported phrase must be 24 BIP-39 English words with a valid checksum; case and spacing are normalised and anything else is refused. There is no BIP-39 passphrase, because it would be a second secret to back up and losing it would look exactly like losing the phrase. A phrase that was used with one elsewhere therefore lands on a different tree here and finds nothing, with nothing to say why. +`address` reads the counter without moving it, so asking twice gives the same answer, and it works for a keyring 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 sweeping the tree loops itself, and no form sweeps keyrings, because no single address answers for one, since what an account holds can go away. -### `bind` - -The active profile is bound to a keyring. Named with `keyring=` it takes that one, which is how a profile bound to the wrong one is put right. Left to itself it takes the lowest keyring that exists and is unbound, and only makes a new one, from a counter on the master, when there is none. That order is what makes a recovery work without anyone naming anything. - -The counter is a high-water mark and not a count of what is held. A profile that already has a keyring is refused, since a profile has one at a time; rebinding means naming the new one, which says plainly that the old one is being let go. - -After an import the counter is unknown rather than zero, because the phrase does not say how many keyrings it has already been used for. Making a new keyring is refused while it is unknown, so an imported phrase cannot make one that already has accounts in use. Binding an existing keyring is still allowed, which is the whole of what a recovered device does. - -BIP-32 hardens an index by adding 2^31 to it, so an index at or above 2^31 wraps into one that is not hardened. That is a silent loss of hardening rather than a collision, and it would put a whole keyring back in reach of the attack the account level stops. Every index this API takes is refused at or above 2^31, both the keyring index and the account index. - -### `address` - -This is where derivation is asked for. It answers with one path and one address, and with the indexes they came from, so a caller that left either out knows what it got. +`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 @@ -144,29 +110,7 @@ data WalletAddress = WalletAddress keyPath :: Text, address :: Text } -``` -`keyring=` left out means the keyring the active profile is bound to, and is refused when it is bound to none. `account=` left out means the next free account, which is what the counter says: the counter is read, not moved, so asking twice gives the same answer. That form is refused where there is no counter to read, which is a keyring with no row and one whose counter is still unknown. - -With `keyring=` given, the keyring does not have to exist in the database. A device that lost its database is looking for keyrings no row was ever written for, so the command works from the master entropy and the two numbers alone. - -One address at a time is all this needs to be. A caller sweeping the tree loops over both numbers itself, and there is deliberately no form that sweeps keyring indexes for it, because no single address answers for a keyring: what an account holds can go away, so account 0 can hold nothing while 1 and 2 still hold names. - -### `export` - -Each form names what it exports, for the same reason `create` names its source. The three hand over very different amounts: the master phrase is every keyring, a keyring phrase is every account under one keyring, and an account key's secret is one account. All three are deterministic, so each also hands over what has not been made yet, accounts taken later and keyrings made later. `export` with no argument is refused, and `export keyring` and `export account` act on the keyring the active profile is bound to and are refused for a profile bound to none. - -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, which is not in this change. Signing is not in this change either, and when it lands it is a command here that signs and returns a signature, not `export account` followed by signing elsewhere. A key that left for routine work would make the narrow export the ordinary path and waste the point of having one. - -### `delete` - -The master entropy and its keyring rows go. Accounts already registered stay registered to their addresses, and only the phrase reaches them again, so a user who has not written the phrase down loses them. Nothing else in the database is touched. - -### Errors - -Every refusal says which it is. - -```haskell data WalletError = WENoMaster -- the device has no master entropy | WEMasterExists -- create, when it already has one @@ -182,34 +126,20 @@ data WalletError Three cases, by how much of the database came back. -### Only the phrase is left +**Only the phrase is left.** A phrase carries entropy and nothing else. Which keyrings were used is not in it, so every counter starts out unknown and only a scan of the chain sets them; which profile was bound to which keyring is not in it either, and the order the user makes profiles in stands in for it. That scan is a nested walk over `address`, one call for each candidate: for each keyring in turn it asks for account 0, then 1, and so on, keeping on while accounts hold something and moving on after a run that holds nothing, and it ends after a run of keyrings that hold nothing at all. Asking what an address owns, deciding how long a run has to be, and recording the answers belong to the caller, because the wallet never sees an answer. Nothing outside this API writes these tables, so a command landing with the scan writes a row for each keyring found, unbound, and moves the master's counter past them. -A phrase carries entropy and nothing else. Two things are not in it and cannot be. The first is which keyrings were used: the master's counter starts out unknown, as does the account counter of any keyring found later, and only a search of the chain sets them. The second is which profile was bound to which keyring: nothing recovers that, and the order the user makes profiles in is what stands in for it. +What the scan finds keeps its own keyring index, nothing is attached to a profile, and the user is asked nothing. As the user makes profiles again and each buys its first name, `bind` takes the lowest unbound keyring, so profiles made in the order they were made before end up on the keyrings they had. A different order shows up as the wrong names, and `bind keyring=` fixes it, moving no key and signing nothing, because ownership does not change, only which profile the app shows the accounts under. A keyring binds whole: splitting its accounts between two profiles would put an account under another keyring, which is an on-chain transfer and not a recovery step. -That search is a nested walk over `address`, one call for each candidate. For each keyring index in turn, the caller asks for account 0, then 1, and so on, and asks what each address owns. It keeps going while accounts turn out to hold something and moves on after a run of accounts that hold nothing. The walk over keyring indexes ends after a run of indexes that hold nothing at all. Asking what an address owns, deciding how long a run has to be, and recording the answers belong to the caller, because the wallet never sees an answer. +**The database is older than the master.** It has the profiles and the keyrings as of the backup and nothing written after it, so its counters are behind. A counter that is behind is worse than one that is unknown, because it looks usable, so such a database is treated as having unknown counters until the same scan fills the gap. -Nothing outside this API writes these tables, so the search does not set the counters itself. It reports what it found, and a command of this API that lands with it writes a row for each keyring found, unbound, and moves the master's counter past them. - -What the search finds keeps its own keyring index. Nothing is attached to a profile and the user is asked nothing. As the user makes chat profiles again and each one buys its first name, `bind` takes the lowest unbound keyring, so profiles made in the same order as before end up on the keyrings they had. That is the whole of the normal case: the user does not have to remember which keyring was which, only to make the profiles in the order they made them before. - -A different order puts a profile on a keyring that was another one's, which shows up as the wrong names. `bind keyring=` fixes it, and fixing it moves no key and signs nothing, because ownership does not change, only which profile the app shows the accounts under. Pointing a name at the new profile's address is a separate signed edit of the name's record. - -A keyring binds whole. Every account under it comes from one seed, so splitting its accounts between two profiles would mean putting an account under another keyring, which is an on-chain transfer of whatever the account holds and not a recovery step. - -### The database is older than the master - -It has the profiles and the keyrings as of the backup, and nothing written after it: keyrings bound since, accounts taken since, and counters that are behind. A counter that is behind is worse than one that is unknown, because it looks usable, so such a database is treated as having unknown counters until the same search fills the gap, over a smaller range. - -### The database is current - -It records which profile is bound to which keyring, so every profile is back on its own and nothing is asked of the user. +**The database is current.** It records which profile is bound to which keyring, so nothing is asked of the user. ## Schema ```sql CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, - entropy BLOB NOT NULL CHECK (length(entropy) IN (16, 20, 24, 28, 32)), + entropy BLOB NOT NULL CHECK (length(entropy) IN (16, 32)), seed_kind TEXT NOT NULL CHECK (seed_kind IN ('master', 'keyring')), next_keyring_index INTEGER -- master only; null means not known yet ); @@ -232,53 +162,30 @@ CREATE UNIQUE INDEX idx_wallet_keyrings_user ON wallet_keyrings(user_id); ``` -Only entropy that nothing can derive is stored. The master is such a row, 16 to 32 bytes, which is 12 to 24 words: generation always makes 24, and a shorter import is still accepted. A keyring's own entropy is never stored, because the master entropy and a keyring index derive it whenever a key is needed. +Only entropy that nothing can derive is stored: the master, always 32 bytes since it is made and imported as 24 words, and later a keyring imported on its own, 16 bytes, which is not implemented here. A derived keyring is never stored, because the master entropy and a keyring index derive it whenever a key is needed. So `wallet_keyrings` holds what derivation cannot produce: which keyring index exists, which stored entropy it belongs to, which profile is bound to it, and how far its account counter has run. A row with no `user_id` is a keyring no profile reaches, which is what a deleted chat profile leaves behind and what a scan writes. -So `wallet_keyrings` holds what derivation cannot produce: which keyring index exists, which stored entropy it belongs to, which profile is bound to it, and how far its account counter has run. A row is a keyring the device knows about, and a row with no `user_id` is one no profile currently reaches. - -`users` is not touched. The binding lives on the keyring row, and the unique index on `user_id` is what makes one keyring for each profile a rule of the database rather than of the caller. Null user ids are distinct, so any number of keyrings can sit unbound. - -Deleting a chat profile leaves its keyring row, unbound, because the accounts under it still hold whatever they held. Deleting stored entropy takes the keyring rows that belong to it, because a keyring index with no entropy behind it derives nothing. - -The migration has no reverse step. Dropping `wallet_seeds` would destroy the only copy of the master entropy, and with it everything the device owns. - -A keyring can be put on entropy of its own, imported rather than derived. The master phrase then does not recover it, and the schema must not suggest otherwise. `seed_kind` says which is which: a `master` row is the device's own, and the only row `next_keyring_index` applies to; a `keyring` row is entropy that arrived on its own, and its single keyring row records a null `keyring_index`, there being no BIP-85 step to write down. Importing is not implemented here; the column exists so that a row written now can be read correctly later, rather than leaving an unmarked row to be guessed at. - -A null `keyring_index` means the row's entropy is the keyring's root, with no BIP-85 step. A keyring row must therefore never combine a null `keyring_index` with the master row, or `export keyring` would print the master phrase. No constraint across two tables can say so in SQLite, so the rule is enforced where rows are written and there is a test for it. +`users` is not touched: the binding lives on the keyring row, and the unique index on `user_id` makes one keyring for each profile a rule of the database rather than of the caller, with null user ids distinct so any number can sit unbound. Deleting stored entropy takes its keyring rows, because a keyring 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. `seed_kind` marks entropy the master does not cover, whose keyring row records a null `keyring_index`; such a row must never point at the master row, or `export keyring` would print the master phrase, and no constraint across two tables can say so in SQLite, so it is enforced where rows are written and there is a test for it. ## Threat model -**Someone with the database file.** Can: convert the stored entropy back into the master phrase, and from it derive every keyring and every account, present and future. Cannot: be shut out by anything in this design. The stored entropy is the phrase in another encoding, so an archive exported to move devices carries every key on the device, and no export granularity helps against a file copy. - -**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, or the keyring above it, or another keyring. - -**Someone with one keyring phrase.** Can: every account under that keyring, including accounts taken later. Cannot: reach another keyring, or the master. - -**A wallet a keyring phrase is imported into.** Can: compute and publish that keyring's account extended public keys, which is what enumerating BIP-44 accounts does, and some wallets send them to a vendor. That hands the vendor every account in the keyring at once, which is what an account for each name otherwise prevents. Cannot: reach another keyring. - -**Whoever answers the recovery search.** Can: see every address the phrase could hold a name on, in one burst, so it links every keyring on the device and recognises addresses that hold nothing yet, which is where future accounts will be. Cannot: derive a key. This is the sharpest cost in the design and it needs an answer before the search ships. - -**A paired device.** Can: run any of these commands, including `export master`, because they are not blocked from one. Only the command text is kept out of logs; the answer is not. +- **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 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, the keyring above it, or another keyring. +- **Someone with one keyring phrase.** Gets every account under that keyring, including accounts taken later. Cannot reach another keyring or the master. +- **A wallet a keyring 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 in the keyring at once. 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 keyring on the device 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. ## Known limits -1. **The phrase alone does not restore a device.** It recovers keys, not which keyrings were used or which profile had which. Until the search lands, an imported phrase gives a device its keys and no way to find what they own. -2. **The same phrase on two devices collides.** The counter lives in one database, so two devices holding one phrase both make the same keyring and each believes it is free. -3. **A profile hidden after it was bound keeps its keyring.** The check happens at bind time, so it does not catch a profile bound first and hidden afterwards. -4. **A deleted chat profile leaves its keyring unbound, and the next profile to bind takes it.** So deleting a profile and making another hands the new one the old one's accounts and names. That is what makes a recovery work without anyone naming a keyring, and it is the wrong answer for a user who deleted a profile to be rid of it. -5. **`address` ignores profiles.** It derives for any index straight from the master, so any caller can enumerate every keyring's addresses, hidden profiles' included. The search needs that, and nothing here narrows it. -6. **Keyring indexes are not dense.** A keyring can be made and never used, and a run of empty keyrings is how the search decides to stop, so a keyring far above a gap can be missed. -7. **Nothing records which layout a seed was used under.** A phrase used in another wallet may hold accounts at paths this doc does not describe. -8. **Gas and discovery pull against each other.** If an account ever pays for anything, whatever funds it links every account in the keyring on chain. If it never pays, it never transacts, which is why no wallet finds it past account 0. -9. **`delete` does not overwrite.** A deleted row survives in free pages and in the journal, and `delete` followed by `create mnemonic=` swaps the wallet for another one with nothing recording that it happened. -10. **Purpose `5564'` is ours.** It is reserved for stealth keys, taken from an [ERC-5564](https://eips.ethereum.org/EIPS/eip-5564) number rather than registered as a BIP-43 purpose, and nothing in this change derives at it. - -## Open questions - -1. Going from an address to the names it owns is not something a registry answers on chain, so the search needs the registrar or an indexer. Which one decides who sees the search, and that is the whole of the privacy cost in the threat model. -2. How long a run of empty accounts, and of empty keyrings, ends a walk. Both are guesses until the search is written, and a user who knows better has to be able to send it further. -3. Whether a keyring freed by deleting a chat profile should be handed to the next profile that binds, as limit 4 has it, or marked so that only keyrings the search found are handed out. The second costs one column. -4. Whether two devices holding one phrase should share the counter, which needs a backup both can read. +1. **The phrase alone does not restore a device.** Until the scan lands, an imported phrase gives a device its keys and no way to find what they own. +2. **The scan's shape is unsettled.** Going from an address to the names it owns is not something a registry answers on chain, so it needs the registrar or an indexer, and which one decides who sees the scan. How long a run of empty accounts, or of empty keyrings, ends a walk is a guess until it is written, and a user who knows better has to be able to send it further. +3. **The same phrase on two devices collides.** The counter lives in one database, so both make the same keyring and each believes it is free. Sharing it needs a backup both can read. +4. **A deleted chat profile leaves its keyring unbound, and the next profile to bind takes it.** That is what makes a recovery work without naming anything, and it is the wrong answer for a user who deleted a profile to be rid of it. Marking only the keyrings a scan found would fix it, at one column. +5. **A profile hidden after it was bound keeps its keyring.** The check is at bind time only. +6. **Keyring indexes are not dense.** A keyring can be made and never used, and a run of empty keyrings is how the scan stops, so one far above a gap can be missed. +7. **Gas and discovery pull against each other.** If an account ever pays for anything, whatever funds it links every account in the keyring on chain. If it never pays, no wallet finds it past account 0. +8. **Nothing records which layout a seed was used under.** A phrase used in another wallet may hold accounts at paths this doc does not describe. +9. **Purpose `5564'` is ours.** Reserved for stealth keys, taken from an [ERC-5564](https://eips.ethereum.org/EIPS/eip-5564) number rather than registered as a BIP-43 purpose, and nothing here derives at it. ## Files @@ -292,10 +199,8 @@ A null `keyring_index` means the row's entropy is the keyring's root, with no BI **File:** `tests/WalletTests.hs`. Each of these is a test, not a claim. -1. **Vectors.** The BIP-85 vectors for application `39'` reproduce from the master key in its specification, at 12, 18 and 24 words, entropy and mnemonic. The two BIP-44 addresses above reproduce from `abandon ... about`. -2. **Interop.** A master phrase reaches an account address end to end, through BIP-85, through a real BIP-39 import of the 12 words it produced, and on to `m/44'/60'/1'/0/0`, so the BIP-39 step between the two trees cannot be skipped unnoticed. A keyring phrase imported on its own as an ordinary mnemonic reaches the same account addresses. +1. **Vectors.** The BIP-85 vectors for application `39'` reproduce from the master key in its specification, and the two BIP-44 addresses above from `abandon ... about`. +2. **Interop.** A master phrase reaches an account address end to end, through BIP-85, through a real BIP-39 import of the 12 words it produced, and on to `m/44'/60'/1'/0/0`, so the BIP-39 step between the two trees cannot be skipped unnoticed. 3. **Isolation.** Two keyrings' first ten account addresses do not intersect, neither keyring's phrase derives the other's, and an account path hardens its account component. -4. **Refusals.** A second generate or import; an imported phrase of a wrong length, a wrong word or a bad checksum; `bind` on a hidden profile, on a profile already bound, and making a new keyring on an imported master; `export` with no argument; `export keyring` for a profile bound to none; every index at or above 2^31. -5. **Binding order.** `bind` with no argument takes the lowest unbound keyring and only makes a new one when there is none, `bind keyring=` rebinds, profiles made in the same order after a recovery end up on the keyrings they had, and no two profiles are bound to one keyring nor one profile to two. -6. **Reads do not write.** `/_wallet` returns no address, `address` with no `account=` returns the counter's value twice running without moving it, and `address` derives for a keyring the database has no row for. -7. **Encoding and persistence.** An account key's secret whose first byte is zero keeps its 64 hex digits; `export keyring` never returns the master phrase whatever a keyring row says; keys and addresses survive a restart. +4. **Refusals.** A second generate or import; a bad phrase; `bind` on a hidden profile, on a profile already bound, and making a keyring on an imported master; `export` with no argument, and `export keyring` for a profile bound to none; every index at or above 2^31. +5. **Binding and reads.** `bind` takes the lowest unbound keyring and rebinds only when named, profiles made in the same order after a recovery land on the keyrings they had, `address` returns the counter twice running without moving it and derives for a keyring with no row, and `export keyring` never returns the master phrase. From 4f21e68972dd265364488c31ad139a666231b52b Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 21 Sep 2026 11:30:05 +0200 Subject: [PATCH 42/67] drop BIP85 and flattenhierarche. no keyrings --- docs/rfcs/2026-09-10-wallet-keys.md | 164 ++++++++++++---------------- 1 file changed, 69 insertions(+), 95 deletions(-) diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 68af0fa422..357f583db6 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -6,118 +6,97 @@ The client has no static keys of its own which could be used for blockchain use The first consumer is [public namespaces](./2026-05-21-public-namespaces.md): a name is a record on a public blockchain saying which address owns it, and every name gets an account of its own, so one chat profile needs many accounts. This is not a names wallet only, though. An account is an ordinary Ethereum account, and [stealth transfers](https://github.com/simplex-chat/simplex-chat/pull/7519) will take accounts the same way. A device also holds several chat profiles, which are meant to stay isolated, and the one phrase the user writes down has to recover all of them. -Out of scope of this doc: buying a name, the names protocol, the registrar, signing, the scanning of the chain and the command that records what it finds, importing entropy for one keyring, more than one master, a backup of the counters shared between devices, and stealth keys. Buying is not implemented yet either, so the keys land first and can be reviewed on their own. +Out of scope of this doc: buying a name, the names protocol, the registrar, signing, the scanning of the chain and the command that records what it finds, importing an account key that the master does not derive, more than one master, and stealth keys. Buying is not implemented yet either, so the keys land first and can be reviewed on their own. ## Security objectives 1. Every key the device uses is derivable again from the master phrase alone. 2. Handing over one account key hands over that account and nothing else, whatever else the holder has. -3. Handing over one keyring phrase hands over that keyring and nothing else. -4. No extended public key links two keyrings, or two accounts within a keyring. -5. Key material is made and handed out only when asked for, never at startup and never as a side effect of reading. -6. A hidden profile owns nothing on chain, so nothing on chain is tied to it. +3. No extended public key links two accounts. +4. Key material is made and handed out only when asked for, never at startup and never as a side effect of reading. +5. A hidden profile owns nothing on chain, so nothing on chain is tied to it. + +There is no unit between the two: the master phrase is every account, and an account secret is one account. Nothing hands over "this profile's accounts", because nothing in the derivation knows about profiles. ## Design -### Keyrings and accounts +### Accounts -A device has one piece of [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) entropy, the **master**, made as 24 words. Nothing signs with it. Every key comes from it in two steps. +A device has one piece of [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) entropy, the **master**, made as 24 words. Nothing signs with it. Every key is a [BIP-44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki) account under it, at a hardened account index, which is how Ledger Live lays out an Ethereum wallet. -First, [BIP-85](https://github.com/bitcoin/bips/blob/master/bip-0085.mediawiki) turns one node of a [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) tree into fresh BIP-39 entropy. The entropy at **keyring index** `r` is a **keyring**: its own 12 words, and every key under them. A keyring is what a chat profile is given, but it is not derived from the profile, and which profile has which keyring is a mapping in the database and nothing more. Second, [BIP-44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki) under a keyring gives each thing it owns on chain an **account index**, and the key there is the **account key**. Names are the first use: one name, one account, and that account's address is what the name record names as its owner. +Each thing the device owns on chain is given an **account index**, and the key there is the **account key**. Names are the first use: one name, one account, and that account's address is what the name record names as its owner. Which chat profile an account belongs to is a mapping in the database. No path mentions a profile, so nothing about a profile reaches the derivation and the two can be rearranged without a key moving. ``` -master seed, 24 words the only thing to back up -└── keyring r m/83696968'/39'/0'/12'/r' BIP-85, its own 12-word mnemonic - ├── m/44'/60'/n'/0/0 accounts, n >= 0 - ├── m/5564'/60'/0'/0'/0 stealth spend (later) - └── m/5564'/60'/0'/1'/0 stealth view (later) +master seed, 24 words the only thing to back up +└── m/44'/60'/n'/0/0 account n, n >= 0 ``` -### Derivation +An account index is BIP-44's own account level, the level [Ledger Live](https://github.com/LedgerHQ/ledger-live-common/blob/HEAD/docs/derivation.md) varies and also calls an account, numbering from one where its Account 1 is index 0. So the master phrase given to another wallet reaches the same addresses and the two tools use the word for the same thing. That wallet finds account 0 and stops, because BIP-44 says to stop at the first account with no transaction history and an account that only owns a name never transacts; above it the user has to enter the path. -BIP-85 derives entropy from a BIP-32 node by taking the bare 32-byte private key of that node and computing `HMAC-SHA512("bip-entropy-from-k", k)`, where `k` is that private key; the letter is the specification's, appears inside the string it fixes, and is not the keyring index. For 12 words the entropy is the leading 16 bytes. In the path, `39'` is BIP-85's BIP-39 application, `0'` is English, `12'` is the word count, `r'` is the keyring index, and every step is hardened. - -Those 16 bytes are BIP-39 entropy, not a seed: they become 12 words, the words become a seed the BIP-39 way, and that seed is the root of a second BIP-32 tree, which is why both path shapes above start at `m`. Feeding the 16 bytes straight into BIP-32 would work inside the app and would silently break every claim here about another wallet. - -An account index is BIP-44's own account level, the level [Ledger Live](https://github.com/LedgerHQ/ledger-live-common/blob/HEAD/docs/derivation.md) varies and also calls an account, numbering from one where its Account 1 is index 0. A keyring phrase given to another wallet reaches the same addresses, but that wallet finds account 0 and stops, because BIP-44 says to stop at the first account with no transaction history and an account that only owns a name never transacts. Above it the user has to enter the path. - -The tests pin BIP-85 against the vectors in its own specification, and BIP-44 against the standard `abandon ... about` test mnemonic, which is 12 words, with an empty BIP-39 passphrase. Addresses are in [EIP-55](https://eips.ethereum.org/EIPS/eip-55) mixed case. +The tests pin the derivation against the standard `abandon ... about` test mnemonic, which is 12 words, with an empty BIP-39 passphrase. Addresses are in [EIP-55](https://eips.ethereum.org/EIPS/eip-55) mixed case. ``` -BIP-85, from the specification's own master key -m/83696968'/39'/0'/12'/0' 6250b68daf746d12a24d58b4787a714b - girl mad pet galaxy egg matter matrix prison refuse sense ordinary nose - -BIP-44, from abandon ... about account 0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94 account 1 m/44'/60'/1'/0/0 0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265 ``` ### Why an account for each name -The alternative is one account holding a profile's names. A name's owner address is public, so that address would link them, and that is as true inside one profile as across two. An account for each name leaves no such link, and the same holds for anything else an account comes to own. +The alternative is one account holding several names. A name's owner address is public, so that address would link them, and that is as true inside one profile as across two. An account for each name leaves no such link, and the same holds for anything else an account comes to own. ### Why the account level is hardened -The alternative is BIP-44's ordinary address level, `m/44'/60'/0'/0/n`, which is what MetaMask enumerates and is therefore the friendlier path. It is not hardened, and BIP-32 has a trap there: the extended public key above a key, together with that one key's private half, yields the parent private key and from it every sibling. Giving a keyring phrase to another wallet is how such an extended public key comes to exist, and giving away one account key is the other half. The two steps below an account are not hardened, so those halves do reach that account's own key, but nothing else is derived under an account and the account level itself is hardened, so they stop there; BIP-85 is hardened throughout, so they reach no other keyring either. That is objectives 2, 3 and 4, and it is worth the loss of MetaMask's default path. +The alternative is BIP-44's ordinary address level, `m/44'/60'/0'/0/n`, which is what MetaMask enumerates and is therefore the friendlier path. It is not hardened, and [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) has a trap there: the extended public key above a key, together with that one key's private half, yields the parent private key and from it every sibling. Handing out one account key is one half, and any wallet that enumerates accounts produces the other. The two steps below an account are not hardened, so those halves do reach that account's own key, but nothing else is derived under an account and the account level itself is hardened, so they stop there and reach no other account. That is objectives 2 and 3, and it is worth the loss of MetaMask's default path. -### Why 24 words for the master and 12 for a keyring +### Why 24 words -Entropy cannot be added to a seed afterwards and what a seed is used for can grow, so the master is 24 words. A keyring is 12, because it is the phrase a user writes down when handing one keyring to another wallet, and 128 bits is the level secp256k1 works at, so nothing is lost at the point of use. The two lengths also tell the phrases apart at a glance: 24 words is everything on the device, 12 words is one keyring. For a keyring the count is part of the BIP-85 path, so it is fixed for good. +Entropy cannot be added to a seed afterwards and what a seed is used for can grow, so the master is 24 words, 256 bits, which is a deliberate choice rather than a default. -## Profiles and keyrings +## Profiles and accounts -A chat profile is bound to one keyring at a time, and a keyring to one profile. The binding is a db mapping, so it can be changed without any key moving, and nothing about the profile reaches the derivation. Profiles can bind no keyring: one is bound 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. Keyring indexes are handed out in order and never reused, because a keyring the device no longer tracks still owns whatever its accounts hold, and a keyring can sit unbound. +An account is bound to at most one chat profile, and a profile to any number of accounts, because a profile can own any number of names. Only one of those names points at the profile as its SimpleX domain name, but which one is the names layer's record, not the wallet's: the wallet says only which profile an account belongs to. -A hidden profile binds no keyring, so it cannot own a name. Two things would leak: the master derives every keyring under it, 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 binds none either. +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. ## Commands An internal API, called by the names commands and by whatever else takes accounts later, rather than typed by users. Nothing here runs at startup or as a side effect of reading. ``` -/_wallet whether the device has a master, and which - keyring the active profile is bound to, and - whether the master derives that keyring +/_wallet whether the device has a master, and the + accounts the active profile is bound to /_wallet create new generate the master, 24 words /_wallet create mnemonic= take the master entropy from a phrase, 24 words -/_wallet bind keyring= bind the active profile to a keyring; without - keyring=, the lowest unbound one, or a new - one when there is none -/_wallet address keyring= account= - one address, with the keyring and account - index it came from; without account=, the - next free account; without keyring=, the one - the active profile is bound to +/_wallet bind account= bind an account to the active profile; + without account=, the next free one +/_wallet address account= one address, with the account index it came + from; without account=, the next free one /_wallet export master the master phrase -/_wallet export keyring the bound keyring's phrase, 12 words /_wallet export account one account key's secret, 0x and 64 hex -/_wallet delete delete the master entropy and its keyrings +/_wallet delete delete the master entropy and its accounts ``` `create` always names its source, so no key material is made by typing a prefix. An imported phrase must be 24 BIP-39 English words with a valid checksum, normalised for case and spacing. There is no BIP-39 passphrase, because it would be a second secret to back up and losing it would look exactly like losing the phrase, so a phrase used with one elsewhere lands on a different tree here and finds nothing. -`bind` without an argument takes the lowest unbound keyring and only makes a new one when there is none, which is what makes a recovery work without anyone naming anything. Naming one is required to rebind, which says plainly that the old keyring is being let go. After an import the master's counter is unknown rather than zero, because the phrase does not say how many keyrings it has been used for, so making a keyring is refused until a search sets it, while binding an existing one 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. +`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. -`address` reads the counter without moving it, so asking twice gives the same answer, and it works for a keyring 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 sweeping the tree loops itself, and no form sweeps keyrings, because no single address answers for one, since what an account holds can go away. +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. + +`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. ```haskell -data WalletAddress = WalletAddress - { keyringIndex :: Word32, - accountIndex :: Word32, - keyPath :: Text, - address :: Text - } +data WalletAddress = WalletAddress {accountIndex :: Word32, keyPath :: Text, address :: Text} data WalletError = WENoMaster -- the device has no master entropy | WEMasterExists -- create, when it already has one | WEBadMnemonic -- wrong word count, wrong word, or bad checksum | WEHiddenProfile -- bind, on a profile the app hides - | WEProfileBound -- bind with no keyring=, on a profile that has one - | WEProfileUnbound -- export or address, on a profile that has none + | WEAccountBound -- bind, on an account another profile holds | WECounterUnknown -- no counter to read yet, after an import | WEIndexTooLarge -- at or above 2^31 ``` @@ -126,64 +105,59 @@ data WalletError Three cases, by how much of the database came back. -**Only the phrase is left.** A phrase carries entropy and nothing else. Which keyrings were used is not in it, so every counter starts out unknown and only a scan of the chain sets them; which profile was bound to which keyring is not in it either, and the order the user makes profiles in stands in for it. That scan is a nested walk over `address`, one call for each candidate: for each keyring in turn it asks for account 0, then 1, and so on, keeping on while accounts hold something and moving on after a run that holds nothing, and it ends after a run of keyrings that hold nothing at all. Asking what an address owns, deciding how long a run has to be, and recording the answers belong to the caller, because the wallet never sees an answer. Nothing outside this API writes these tables, so a command landing with the scan writes a row for each keyring found, unbound, and moves the master's counter past them. +**Only the phrase is left.** A phrase carries entropy and nothing else. Which accounts were used is not in it, so the counter starts out unknown and only a scan of the chain sets it; which profile held which account is not in it either, and nothing recovers that. The scan is a walk over `address`, one call for each candidate: account 0, then 1, and so on, asking what each address owns and stopping after a run that owns nothing. Asking what an address owns, deciding how long that run has to be, and recording the answers belong to the caller, because the wallet never sees an answer. Nothing outside this API writes these tables, so a command landing with the scan writes a row for each account found, unbound, and moves the counter past them. -What the scan finds keeps its own keyring index, nothing is attached to a profile, and the user is asked nothing. As the user makes profiles again and each buys its first name, `bind` takes the lowest unbound keyring, so profiles made in the order they were made before end up on the keyrings they had. A different order shows up as the wrong names, and `bind keyring=` fixes it, moving no key and signing nothing, because ownership does not change, only which profile the app shows the accounts under. A keyring binds whole: splitting its accounts between two profiles would put an account under another keyring, which is an on-chain transfer and not a recovery step. +What the scan finds is unbound, and the user attaches each account to a profile with `bind account=`. The names those accounts own are what identify them, which is what makes the question answerable at all: the user is choosing between names they recognise, not between numbers. Binding moves no key and signs nothing, because ownership does not change, only which profile the app shows the account under. Pointing a name at that profile's address is a separate signed edit of the name's record. -**The database is older than the master.** It has the profiles and the keyrings as of the backup and nothing written after it, so its counters are behind. A counter that is behind is worse than one that is unknown, because it looks usable, so such a database is treated as having unknown counters until the same scan fills the gap. +**The database is older than the master.** It has the accounts as of the backup and nothing written after it, so its counter is behind. A counter that is behind is worse than one that is unknown, because it looks usable, so such a database is treated as having an unknown counter until the same scan fills the gap. -**The database is current.** It records which profile is bound to which keyring, so nothing is asked of the user. +**The database is current.** It records which profile holds which account, so nothing is asked of the user. ## Schema ```sql CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, - entropy BLOB NOT NULL CHECK (length(entropy) IN (16, 32)), - seed_kind TEXT NOT NULL CHECK (seed_kind IN ('master', 'keyring')), - next_keyring_index INTEGER -- master only; null means not known yet + entropy BLOB NOT NULL CHECK (length(entropy) = 32), + next_account_index INTEGER, -- null means not known yet + single_seed INTEGER NOT NULL DEFAULT 1 ); -CREATE TABLE wallet_keyrings ( - wallet_keyring_id INTEGER PRIMARY KEY AUTOINCREMENT, +CREATE TABLE wallet_accounts ( + wallet_account_id INTEGER PRIMARY KEY AUTOINCREMENT, wallet_seed_id INTEGER NOT NULL REFERENCES wallet_seeds ON DELETE CASCADE, - keyring_index INTEGER, -- null when the entropy was imported - user_id INTEGER REFERENCES users ON DELETE SET NULL, - next_account_index INTEGER -- from 0; null means not known yet + account_index INTEGER, -- null when the key was imported + user_id INTEGER REFERENCES users ON DELETE SET NULL ); -CREATE UNIQUE INDEX idx_wallet_seeds_master - ON wallet_seeds(seed_kind) WHERE seed_kind = 'master'; -CREATE UNIQUE INDEX idx_wallet_keyrings_index - ON wallet_keyrings(wallet_seed_id, keyring_index); -CREATE UNIQUE INDEX idx_wallet_keyrings_imported - ON wallet_keyrings(wallet_seed_id) WHERE keyring_index IS NULL; -CREATE UNIQUE INDEX idx_wallet_keyrings_user - ON wallet_keyrings(user_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); ``` -Only entropy that nothing can derive is stored: the master, always 32 bytes since it is made and imported as 24 words, and later a keyring imported on its own, 16 bytes, which is not implemented here. A derived keyring is never stored, because the master entropy and a keyring index derive it whenever a key is needed. So `wallet_keyrings` holds what derivation cannot produce: which keyring index exists, which stored entropy it belongs to, which profile is bound to it, and how far its account counter has run. A row with no `user_id` is a keyring no profile reaches, which is what a deleted chat profile leaves behind and what a scan writes. +Only entropy that nothing can derive is stored: the master, always 32 bytes, since it is 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 binding lives on the keyring row, and the unique index on `user_id` makes one keyring for each profile a rule of the database rather than of the caller, with null user ids distinct so any number can sit unbound. Deleting stored entropy takes its keyring rows, because a keyring 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. `seed_kind` marks entropy the master does not cover, whose keyring row records a null `keyring_index`; such a row must never point at the master row, or `export keyring` would print the master phrase, and no constraint across two tables can say so in SQLite, so it is enforced where rows are written and there is a test for it. +`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. + +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 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, the keyring above it, or another keyring. -- **Someone with one keyring phrase.** Gets every account under that keyring, including accounts taken later. Cannot reach another keyring or the master. -- **A wallet a keyring 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 in the keyring at once. 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 keyring on the device 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. +- **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. ## Known limits -1. **The phrase alone does not restore a device.** Until the scan lands, an imported phrase gives a device its keys and no way to find what they own. -2. **The scan's shape is unsettled.** Going from an address to the names it owns is not something a registry answers on chain, so it needs the registrar or an indexer, and which one decides who sees the scan. How long a run of empty accounts, or of empty keyrings, ends a walk is a guess until it is written, and a user who knows better has to be able to send it further. -3. **The same phrase on two devices collides.** The counter lives in one database, so both make the same keyring and each believes it is free. Sharing it needs a backup both can read. -4. **A deleted chat profile leaves its keyring unbound, and the next profile to bind takes it.** That is what makes a recovery work without naming anything, and it is the wrong answer for a user who deleted a profile to be rid of it. Marking only the keyrings a scan found would fix it, at one column. -5. **A profile hidden after it was bound keeps its keyring.** The check is at bind time only. -6. **Keyring indexes are not dense.** A keyring can be made and never used, and a run of empty keyrings is how the scan stops, so one far above a gap can be missed. -7. **Gas and discovery pull against each other.** If an account ever pays for anything, whatever funds it links every account in the keyring on chain. If it never pays, no wallet finds it past account 0. +1. **There is no per-profile handover.** The master phrase is every account on the device and an account secret is one account, with nothing in between, so a user who wants to hand one profile's names to another wallet hands over one key for each name. +2. **The phrase alone does not restore a device.** Until the scan lands, an imported phrase gives a device its keys and no way to find what they own. +3. **The scan's shape is unsettled.** Going from an address to the names it owns is not something a registry answers on chain, so it needs the registrar or an indexer, and which one decides who sees the scan. How long a run of empty accounts ends the walk is a guess until it is written, and a user who knows better has to be able to send it further. +4. **The same phrase on two devices collides.** The counter lives in one database, so both take the same account and each believes it is free. Sharing it needs a backup both can read. +5. **A profile hidden after it was bound keeps its accounts.** The check is at bind time only. +6. **Account indexes are not dense.** An account can be taken and never used, and a run of empty accounts is how the scan stops, so one far above a gap can be missed. +7. **Gas and discovery pull against each other.** If an account ever pays for anything, whatever funds it links accounts on chain. If it never pays, no wallet finds it past account 0. 8. **Nothing records which layout a seed was used under.** A phrase used in another wallet may hold accounts at paths this doc does not describe. 9. **Purpose `5564'` is ours.** Reserved for stealth keys, taken from an [ERC-5564](https://eips.ethereum.org/EIPS/eip-5564) number rather than registered as a BIP-43 purpose, and nothing here derives at it. @@ -193,14 +167,14 @@ Only entropy that nothing can derive is stored: the master, always 32 bytes sinc - `src/Simplex/Chat/Store/Wallets.hs`, the two tables. - `src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs` and the Postgres twin. - `tests/WalletTests.hs`. -- BIP-85 lands in simplexmq as `Simplex.Messaging.Crypto.BIP85`, beside `BIP32` and `BIP39`, and adds no dependency, because the HMAC-SHA512 it needs is the one BIP-32 already uses. +- Derivation uses the `BIP32` and `BIP39` modules already in simplexmq and adds no dependency. ## What is verified **File:** `tests/WalletTests.hs`. Each of these is a test, not a claim. -1. **Vectors.** The BIP-85 vectors for application `39'` reproduce from the master key in its specification, and the two BIP-44 addresses above from `abandon ... about`. -2. **Interop.** A master phrase reaches an account address end to end, through BIP-85, through a real BIP-39 import of the 12 words it produced, and on to `m/44'/60'/1'/0/0`, so the BIP-39 step between the two trees cannot be skipped unnoticed. -3. **Isolation.** Two keyrings' first ten account addresses do not intersect, neither keyring's phrase derives the other's, and an account path hardens its account component. -4. **Refusals.** A second generate or import; a bad phrase; `bind` on a hidden profile, on a profile already bound, and making a keyring on an imported master; `export` with no argument, and `export keyring` for a profile bound to none; every index at or above 2^31. -5. **Binding and reads.** `bind` takes the lowest unbound keyring and rebinds only when named, profiles made in the same order after a recovery land on the keyrings they had, `address` returns the counter twice running without moving it and derives for a keyring with no row, and `export keyring` never returns the master phrase. +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. From bfc4e1fbaa35ccab76aa60206f90e035899a57d7 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 21 Sep 2026 14:42:34 +0200 Subject: [PATCH 43/67] /goal iteration 4 - unconfirmed --- bots/api/TYPES.md | 37 ++ bots/src/API/Docs/Commands.hs | 12 +- bots/src/API/Docs/Responses.hs | 5 +- bots/src/API/Docs/Types.hs | 5 + docs/rfcs/2026-09-10-wallet-keys.md | 30 +- .../types/typescript/src/types.ts | 66 ++++ .../src/simplex_chat/types/_types.py | 45 ++- src/Simplex/Chat/Controller.hs | 34 +- src/Simplex/Chat/Library/Commands.hs | 119 +++--- src/Simplex/Chat/Remote.hs | 4 +- .../Migrations/M20260908_wallet_seeds.hs | 20 +- .../Migrations/M20260908_wallet_seeds.hs | 22 +- .../Store/SQLite/Migrations/chat_schema.sql | 18 +- src/Simplex/Chat/Store/Wallets.hs | 144 +++++-- src/Simplex/Chat/View.hs | 29 +- src/Simplex/Chat/Wallet.hs | 132 +++++-- tests/WalletTests.hs | 356 ++++++++++++------ 17 files changed, 798 insertions(+), 280 deletions(-) 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" From 768a230f21a5fd8e4a07e0722e593cfaa14fb046 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 21 Sep 2026 17:03:54 +0200 Subject: [PATCH 44/67] /goal iteration 5 - unconfirmed --- bots/src/API/Docs/Types.hs | 2 - docs/rfcs/2026-09-10-wallet-keys.md | 23 +- src/Simplex/Chat/Library/Commands.hs | 44 ++-- src/Simplex/Chat/Store/Postgres/Migrations.hs | 2 +- .../Migrations/M20260908_wallet_seeds.hs | 20 +- .../Store/Postgres/Migrations/chat_schema.sql | 50 ++++- src/Simplex/Chat/Store/SQLite/Migrations.hs | 2 +- .../Migrations/M20260908_wallet_seeds.hs | 21 +- .../Store/SQLite/Migrations/chat_schema.sql | 4 +- src/Simplex/Chat/Store/Wallets.hs | 90 +++++--- src/Simplex/Chat/Wallet.hs | 33 +-- tests/PostgresSchemaDump.hs | 35 +-- tests/SchemaDump.hs | 38 ++-- tests/WalletTests.hs | 202 +++++++++++------- 14 files changed, 328 insertions(+), 238 deletions(-) diff --git a/bots/src/API/Docs/Types.hs b/bots/src/API/Docs/Types.hs index babbbe4e3d..c856735027 100644 --- a/bots/src/API/Docs/Types.hs +++ b/bots/src/API/Docs/Types.hs @@ -628,9 +628,7 @@ 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 6aae485342..058da43cbf 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -110,7 +110,7 @@ Three cases, by how much of the database came back. What the scan finds is unbound, and the user attaches each account to a profile with `bind account=`. The names those accounts own are what identify them, which is what makes the question answerable at all: the user is choosing between names they recognise, not between numbers. Binding moves no key and signs nothing, because ownership does not change, only which profile the app shows the account under. Pointing a name at that profile's address is a separate signed edit of the name's record. -**The database is older than the master.** It has the accounts as of the backup and nothing written after it, so its counter is behind. A counter that is behind is worse than one that is unknown, because it looks usable, so such a database is treated as having an unknown counter until the same scan fills the gap. +**The database is older than the master.** It has the accounts as of the backup and nothing written after it, so its counter is behind. A counter that is behind is worse than one that is unknown, because it looks usable and hands out an account the master has already used. Nothing here can tell a restored database from a current one, so clearing the counter belongs to whatever restores one, along with the scan that fills it in again. **The database is current.** It records which profile holds which account, so nothing is asked of the user. @@ -132,13 +132,13 @@ CREATE TABLE wallet_accounts ( ); 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 UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_accounts(wallet_seed_id, account_index); +CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id); ``` Only entropy that nothing can derive is stored: the master, always 32 bytes, since it is made and imported as 24 words. An account key is never stored, because the master entropy and an account index derive it whenever one is needed. So `wallet_accounts` holds what derivation cannot produce, which account indexes the device knows about and which profile each belongs to. A row with no `user_id` is an account no profile holds, which is what a deleted chat profile leaves behind and what a scan writes. -`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. +`users` is not touched: the mapping lives on the account row, and the index on `user_id` is not unique, because a profile owns as many accounts as it owns names. One seed per device is `single_seed` and the unique index on it, which a later change lifts with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop one of those without rebuilding the table. Deleting the master takes its account rows, because an account index with no entropy behind it derives nothing. The migration has no reverse step, because reversing it would drop the only copy of the master entropy. What runs a reverse step is an older app installed over a newer database, which on mobile happens without asking and leaves one backup file that the next upgrade overwrites; with no reverse step that older app reports instead that the database is newer than it is, and changes nothing. A null `account_index` marks an account whose key was imported rather than derived, which the master phrase does not recover and the schema must not suggest it does. Importing one is not implemented here; the column is nullable now so that a row written later reads correctly, rather than leaving an unmarked row to be guessed at. @@ -149,7 +149,8 @@ A null `account_index` marks an account whose key was imported rather than deriv - **A wallet the master phrase is imported into.** Enumerating BIP-44 accounts computes account extended public keys, and some wallets send them to a vendor, which hands that vendor every account on the device at once, across every profile. That is what an account for each name otherwise prevents. - **Whoever answers the recovery scan.** Sees every address the phrase could hold a name on, in one burst, so it links every account on the device, across profiles, and recognises addresses that hold nothing yet, which is where future accounts will be. `address` derives for any index straight from the master, so a caller can enumerate hidden profiles' addresses too. This is the sharpest cost in the design. - **A paired device.** Can run any of these commands, because they are not blocked from one: `export master` reads the whole wallet, `create` on a device that has none plants a seed the pairing controls, and `delete` destroys the only copy. Blocking `ExecChatStoreSQL` while allowing `export master` is not a coherent line, and the wallet commands need their own decision rather than the catch-all. -- **Someone reading the logs.** 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. +- **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. +- **A page open in the user's browser.** The websocket server in `apps/simplex-chat/Server.hs` accepts any local connection, asks for no token and checks no `Origin`, and websockets are not bound by the same origin policy, so any page loaded while that server runs can send `export master` and read the answer. It also prints every command it receives, that phrase included. Both are properties of that server, which this change gives something worth taking, and closing them is work there rather than in the wallet. ## Known limits @@ -161,7 +162,8 @@ A null `account_index` marks an account whose key was imported rather than deriv 6. **Account indexes are not dense.** An account can be taken and never used, and a run of empty accounts is how the scan stops, so one far above a gap can be missed. 7. **Gas and discovery pull against each other.** If an account ever pays for anything, whatever funds it links accounts on chain. If it never pays, no wallet finds it past account 0. 8. **Nothing records which layout a seed was used under.** A phrase used in another wallet may hold accounts at paths this doc does not describe. -9. **Purpose `5564'` is ours.** Reserved for stealth keys, taken from an [ERC-5564](https://eips.ethereum.org/EIPS/eip-5564) number rather than registered as a BIP-43 purpose, and nothing here derives at it. +9. **A restored database hands out an account that is already used.** Its counter is behind what the master has reached, and nothing detects that, so a name can be bought with an account that already owns one until the scan resets the counter. +10. **Purpose `5564'` is ours.** Reserved for stealth keys, taken from an [ERC-5564](https://eips.ethereum.org/EIPS/eip-5564) number rather than registered as a BIP-43 purpose, and nothing here derives at it. ## Files @@ -169,14 +171,15 @@ A null `account_index` marks an account whose key was imported rather than deriv - `src/Simplex/Chat/Store/Wallets.hs`, the two tables. - `src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs` and the Postgres twin. - `tests/WalletTests.hs`. +- `tests/SchemaDump.hs` and `tests/PostgresSchemaDump.hs`, which selected what to test by taking every migration after the last one without a reverse step, and now take every migration from the first one that has a reverse step, applying any that has none. - Derivation uses the `BIP32` and `BIP39` modules already in simplexmq and adds no dependency. ## What is verified **File:** `tests/WalletTests.hs`. Each of these is a test, not a claim. -1. **Vectors.** The two addresses above reproduce from `abandon ... about`, as does account 0's secret, pinned to the value another wallet shows for it. A 24 word phrase imported through the command reaches a pinned address end to end, so a change of path fails here rather than shipping. +1. **Vectors.** The two addresses above reproduce from `abandon ... about`, as does account 0's secret, pinned to the value another wallet shows for it. A 24 word phrase imported through the command reaches a pinned address end to end, so a change of path fails here rather than shipping, and the account a command names is the account whose key comes back. 2. **Isolation.** Ten accounts' addresses are all different, and an account path hardens its account component. -3. **Refusals.** A second generate; a phrase that is not 24 valid words; `bind` 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. +3. **Refusals.** A second generate; a phrase that is not 24 valid words; `bind`, `delete` and `export master` on a device with no wallet; `bind` on a hidden profile, on an account another profile holds, and on an imported master whose counter is unknown; `export account` for an account 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 and exports its own, an account bound by index moves the counter past it so the next one does not collide and never moves it back, `bind account=` attaches a scanned one, an account a deleted profile leaves behind is taken by another profile, and `address` returns the counter twice running without moving it and derives for an account with no row. +5. **Encoding and persistence.** An account secret whose first byte is zero keeps its 64 hex digits; the wallet, its accounts, the counter and the phrase survive a restart; and deleting the wallet takes its accounts and starts the counter over. diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 16644ab8fa..ba1ec6323b 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 (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.Chat.Store.Wallets (WalletSeed (..), accountHeldByOther, bindAccount, createWalletSeed, deleteWalletSeed, getUserAccounts, getWalletSeed, resolveAccount) +import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress (..), WalletError (..), accountSecret, checkAccountIndex, deriveAccountKey, entropyFromMnemonic, newSeedEntropy, renderAccountPath, seedMaster, seedMnemonic) import Simplex.Messaging.Eth.Address (addressFromPrivateKey) import Simplex.Chat.Call import Simplex.Chat.Controller @@ -1503,27 +1503,25 @@ processChatCommand cxt nm = \case 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_) + liftWallet =<< withFastStore' (\db -> bindAccount db userId accountIdx_) processChatCommand cxt nm APIGetWallet APIGetWalletAddress accountIdx_ -> withUser $ \user -> do - seed <- walletSeed - n <- resolveAccount seed accountIdx_ + (seed, n) <- liftWallet =<< withFastStore' (`resolveAccount` accountIdx_) CRWalletAddress user . accountAddress n <$> accountKey seed n APIExportWalletMnemonic -> withUser $ \user -> - CRWalletMnemonic user <$> (liftWallet . seedMnemonic =<< walletSeed) - APIExportWalletAccount accountIdx -> withUser $ \user@User {userId} -> do + CRWalletMnemonic user <$> (liftWallet . seedMnemonic . wsEntropy =<< walletSeed) + APIExportWalletAccount n -> withUser $ \user@User {userId} -> do seed <- walletSeed - n <- resolveAccount seed (Just accountIdx) + liftWallet $ checkAccountIndex n -- 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) + deleted <- withFastStore' deleteWalletSeed + unless deleted $ throwWalletError WENoMaster ok_ APISendCallInvitation contactId callType -> withUser $ \user -> do -- party initiating call @@ -5470,19 +5468,8 @@ throwWalletError = throwChatError . CEWallet 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) +accountKey seed n = liftWallet $ seedMaster (wsEntropy seed) >>= (`deriveAccountKey` n) accountAddress :: AccountIndex -> AccountKey -> WalletAddress accountAddress n k = @@ -6156,12 +6143,15 @@ chatCommandP = quotedP = safeDecodeUtf8 <$> (A.char '"' *> A.takeTill (== '"') <* A.char '"') text1P = safeDecodeUtf8 <$> A.takeTill (== ' ') char_ = optional . A.char - -- 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. + -- The digits are counted before they are read, as reading a very long + -- number is not free. Ten of them fit an Int with room to spare, and the + -- hardening bound is a typed error when the command runs, so that a caller + -- is told which index was refused and why. accountIndexP = do ds <- A.takeWhile1 isDigit - let i = read (B.unpack ds) :: Integer - if B.length ds <= 10 && i <= toInteger (maxBound :: AccountIndex) then pure (fromIntegral i) else fail "account index too large" + case if B.length ds <= 10 then B.readInt ds else Nothing of + Just (i, _) | i <= fromIntegral (maxBound :: AccountIndex) -> pure $ fromIntegral i + _ -> fail "account index too large" displayNameP :: Parser Text displayNameP = safeDecodeUtf8 <$> displayNameP_ diff --git a/src/Simplex/Chat/Store/Postgres/Migrations.hs b/src/Simplex/Chat/Store/Postgres/Migrations.hs index 09b1129ef8..05a21bed47 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations.hs @@ -99,7 +99,7 @@ schemaMigrations = ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations), ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link), ("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry), - ("20260908_wallet_seeds", m20260908_wallet_seeds, Just down_m20260908_wallet_seeds) + ("20260908_wallet_seeds", m20260908_wallet_seeds, Nothing) ] -- | The list of migrations in ascending order by date 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 03cb0bc027..f4bd3ca2d7 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260908_wallet_seeds.hs @@ -9,10 +9,10 @@ import Text.RawString.QQ (r) m20260908_wallet_seeds :: Text m20260908_wallet_seeds = [r| +-- the columns are commented in the SQLite migration CREATE TABLE wallet_seeds ( wallet_seed_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, entropy BYTEA NOT NULL CHECK (length(entropy) = 32), - -- see the SQLite migration next_account_index BIGINT CHECK (next_account_index BETWEEN 0 AND 2147483648), single_seed SMALLINT NOT NULL DEFAULT 1 ); @@ -25,20 +25,8 @@ CREATE TABLE wallet_accounts ( ); 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 UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_accounts(wallet_seed_id, account_index); +CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id); |] -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; -|] +-- no reverse step, see the SQLite migration diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 1cdf43e011..a0d062e1f7 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1533,11 +1533,34 @@ ALTER TABLE test_chat_schema.users ALTER COLUMN user_id ADD GENERATED ALWAYS AS +CREATE TABLE test_chat_schema.wallet_accounts ( + wallet_account_id bigint NOT NULL, + wallet_seed_id bigint NOT NULL, + account_index bigint, + user_id bigint, + CONSTRAINT wallet_accounts_account_index_check CHECK (((account_index >= 0) AND (account_index <= 2147483647))) +); + + + +ALTER TABLE test_chat_schema.wallet_accounts ALTER COLUMN wallet_account_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.wallet_accounts_wallet_account_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.wallet_seeds ( wallet_seed_id bigint NOT NULL, entropy bytea NOT NULL, - next_name_index bigint DEFAULT 1 NOT NULL, - single_seed smallint DEFAULT 1 NOT NULL + next_account_index bigint, + single_seed smallint DEFAULT 1 NOT NULL, + CONSTRAINT wallet_seeds_entropy_check CHECK ((length(entropy) = 32)), + CONSTRAINT wallet_seeds_next_account_index_check CHECK (((next_account_index >= 0) AND (next_account_index <= '2147483648'::bigint))) ); @@ -1911,6 +1934,11 @@ ALTER TABLE ONLY test_chat_schema.users +ALTER TABLE ONLY test_chat_schema.wallet_accounts + ADD CONSTRAINT wallet_accounts_pkey PRIMARY KEY (wallet_account_id); + + + ALTER TABLE ONLY test_chat_schema.wallet_seeds ADD CONSTRAINT wallet_seeds_pkey PRIMARY KEY (wallet_seed_id); @@ -2665,6 +2693,14 @@ CREATE UNIQUE INDEX idx_user_contact_links_group_id ON test_chat_schema.user_con +CREATE INDEX idx_wallet_accounts_user_id ON test_chat_schema.wallet_accounts USING btree (user_id); + + + +CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON test_chat_schema.wallet_accounts USING btree (wallet_seed_id, account_index); + + + CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON test_chat_schema.wallet_seeds USING btree (single_seed); @@ -3344,6 +3380,16 @@ ALTER TABLE ONLY test_chat_schema.user_contact_links +ALTER TABLE ONLY test_chat_schema.wallet_accounts + ADD CONSTRAINT wallet_accounts_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE SET NULL; + + + +ALTER TABLE ONLY test_chat_schema.wallet_accounts + ADD CONSTRAINT wallet_accounts_wallet_seed_id_fkey FOREIGN KEY (wallet_seed_id) REFERENCES test_chat_schema.wallet_seeds(wallet_seed_id) ON DELETE CASCADE; + + + ALTER TABLE ONLY test_chat_schema.xftp_file_descriptions ADD CONSTRAINT xftp_file_descriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations.hs b/src/Simplex/Chat/Store/SQLite/Migrations.hs index b7ce50316e..86e0e5ffff 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations.hs @@ -345,7 +345,7 @@ schemaMigrations = ("20260813_auto_accept_group_invitations", m20260813_auto_accept_group_invitations, Just down_m20260813_auto_accept_group_invitations), ("20260822_forward_link", m20260822_forward_link, Just down_m20260822_forward_link), ("20260828_file_expiry", m20260828_file_expiry, Just down_m20260828_file_expiry), - ("20260908_wallet_seeds", m20260908_wallet_seeds, Just down_m20260908_wallet_seeds) + ("20260908_wallet_seeds", m20260908_wallet_seeds, Nothing) ] -- | The list of migrations in ascending order by date 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 9a289e6ea3..18acfcef24 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260908_wallet_seeds.hs @@ -23,20 +23,11 @@ CREATE TABLE wallet_accounts ( ) 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); +CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_accounts(wallet_seed_id, account_index); +CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id); |] -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; -|] +-- There is no reverse step. Reversing this would drop the only copy of the +-- master entropy, and a downgrade that takes every key on the device with it is +-- worse than one that refuses: without a reverse step the older app reports that +-- the database is newer than it is and changes nothing. diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 65d0c73032..a455439204 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -1399,11 +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( +CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_accounts( wallet_seed_id, account_index ); -CREATE INDEX idx_wallet_accounts_user ON wallet_accounts(user_id); +CREATE INDEX idx_wallet_accounts_user_id 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 9f9a77a8d3..ef39533c26 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -1,28 +1,31 @@ {-# LANGUAGE CPP #-} {-# LANGUAGE LambdaCase #-} +{-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE TypeApplications #-} -- | The device seed, and which chat profile each account belongs to. module Simplex.Chat.Store.Wallets - ( getWalletSeed, + ( SeedId, + WalletSeed (..), + getWalletSeed, createWalletSeed, deleteWalletSeed, - getNextAccountIndex, + resolveAccount, getUserAccounts, accountHeldByOther, bindAccount, ) where -import Control.Monad (join, when) +import Control.Monad (join, unless, 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 (AccountIndex, SeedId, WalletError (..), WalletSeed (..), checkAccountIndex) +import Simplex.Chat.Wallet (AccountIndex, WalletError (..), checkAccountIndex) import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.DB as DB @@ -35,6 +38,17 @@ import Database.SQLite.Simple (Only (..)) import Database.SQLite.Simple.QQ (sql) #endif +type SeedId = Int64 + +-- | The device seed, as a row: the id the account rows are counted against, and +-- the entropy every key is derived from. The entropy is 'BA.ScrubbedBytes', so +-- a derived 'Show' does not print it. +data WalletSeed = WalletSeed + { wsId :: SeedId, + wsEntropy :: BA.ScrubbedBytes + } + deriving (Show) + toSeed :: (Int64, ByteString) -> WalletSeed toSeed (sId, entropy) = WalletSeed {wsId = sId, wsEntropy = BA.convert entropy} @@ -56,8 +70,26 @@ createWalletSeed db entropy nextAccount = "INSERT INTO wallet_seeds (entropy, next_account_index) VALUES (?, ?)" (DB.Binary (BA.convert entropy :: ByteString), accountIndexCol <$> nextAccount) -deleteWalletSeed :: DB.Connection -> SeedId -> IO () -deleteWalletSeed db sId = DB.execute db "DELETE FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId) +-- | False if the device had no seed to delete. The account rows go with it. +deleteWalletSeed :: DB.Connection -> IO Bool +deleteWalletSeed db = + getWalletSeed db >>= \case + Nothing -> pure False + Just WalletSeed {wsId} -> + True <$ DB.execute db "DELETE FROM wallet_seeds WHERE wallet_seed_id = ?" (Only wsId) + +-- | The seed, and the account an argument names or the next free one from the +-- counter when it names none. Refuses an index BIP-32 cannot harden, the +-- counter's included. The seed comes back with it so that a caller derives from +-- the one the index was resolved against. +resolveAccount :: DB.Connection -> Maybe AccountIndex -> IO (Either WalletError (WalletSeed, AccountIndex)) +resolveAccount db accountIdx_ = runExceptT $ do + seed <- ExceptT $ maybe (Left WENoMaster) Right <$> getWalletSeed db + n <- maybe (nextFreeAccount $ wsId seed) pure accountIdx_ + liftEither $ checkAccountIndex n + pure (seed, n) + where + nextFreeAccount sId = ExceptT $ maybe (Left WECounterUnknown) Right <$> getNextAccountIndex db 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. @@ -87,9 +119,10 @@ 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. +-- | True when a profile other than this one holds the account, which keeps one +-- profile from handing out another's account key. It is a guard, not a +-- boundary: @export master@ reaches every account from any profile. An account +-- no profile holds is not another profile's. heldByOther :: UserId -> Maybe (Maybe Int64) -> Bool heldByOther userId = \case Just (Just heldBy) -> heldBy /= userId @@ -99,32 +132,37 @@ accountHeldByOther :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bo 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 +-- scan found, or the next free one when no index is given. The whole command is +-- one transaction, so nothing between reading the counter and taking the +-- account can hand the same one out twice. +bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> IO (Either WalletError ()) +bindAccount db userId accountIdx_ = runExceptT $ do + (WalletSeed {wsId = sId}, n) <- ExceptT $ resolveAccount db accountIdx_ 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 + taken <- liftIO $ case held of + Just (Just _) -> pure True -- already this profile's + -- the update takes the account only while no profile holds it, and the read + -- after it says whether this one got it: where transactions are not + -- serialised, two of them can both read the account as free + Just Nothing -> setAccountUser db sId userId n >> accountHeldBy db sId userId n + Nothing -> True <$ insertAccount db sId userId n + unless taken $ throwError WEAccountBound + liftIO $ raiseNextAccount db sId n 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 = ? |] + [sql| + UPDATE wallet_accounts SET user_id = ? + WHERE wallet_seed_id = ? AND account_index = ? AND user_id IS NULL + |] (userId, sId, accountIndexCol n) +accountHeldBy :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bool +accountHeldBy db sId userId n = (== Just (Just userId)) <$> accountUser db sId 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. diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index 476a4d44f9..7ecad22bfc 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -10,10 +10,8 @@ -- 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, + ( AccountIndex, AccountKey, - WalletSeed (..), WalletAddress (..), WalletError (..), newSeedEntropy, @@ -33,7 +31,6 @@ 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) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1) import Data.Word (Word32) @@ -43,8 +40,6 @@ 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 account index. One account owns one thing on chain, one name to -- begin with. type AccountIndex = Word32 @@ -52,15 +47,6 @@ 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 :: BA.ScrubbedBytes - } - deriving (Eq, Show) - -- | One derived address, with the index it came from, so a caller that left the -- index out knows what it got. data WalletAddress = WalletAddress @@ -101,14 +87,14 @@ entropyFromMnemonic phrase = case B39.parseMnemonic phrase of Right . BA.convert $ B39.mnemonicToEntropy m _ -> Left WEBadMnemonic -seedMnemonic :: WalletSeed -> Either WalletError Text -seedMnemonic s = - bipError . fmap (decodeLatin1 . B39.mnemonicPhrase) . B39.entropyToMnemonic $ entropyBytes s +seedMnemonic :: BA.ScrubbedBytes -> Either WalletError Text +seedMnemonic entropy = + bipError . fmap (decodeLatin1 . B39.mnemonicPhrase) . B39.entropyToMnemonic $ entropyBytes entropy -- | Deriving this runs PBKDF2, so it is done once per command. -seedMaster :: WalletSeed -> Either WalletError B32.ExtendedKey -seedMaster s = do - m <- bipError . B39.entropyToMnemonic $ entropyBytes s +seedMaster :: BA.ScrubbedBytes -> Either WalletError B32.ExtendedKey +seedMaster entropy = do + m <- bipError . B39.entropyToMnemonic $ entropyBytes entropy bipError . B32.masterKey $ B39.mnemonicToSeed m "" accountPath :: AccountIndex -> [Word32] @@ -124,8 +110,9 @@ deriveAccountKey master n = B32.xkKey <$> bipError (B32.derivePath master $ acco accountSecret :: AccountKey -> Text accountSecret k = "0x" <> decodeLatin1 (BAE.convertToBase BAE.Base16 $ S.unPrivateKey k) -entropyBytes :: WalletSeed -> ByteString -entropyBytes = BA.convert . wsEntropy +-- | The copy BIP-39 takes is a plain 'ByteString' and is not wiped. +entropyBytes :: BA.ScrubbedBytes -> ByteString +entropyBytes = BA.convert -- | 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 diff --git a/tests/PostgresSchemaDump.hs b/tests/PostgresSchemaDump.hs index b36b7faeaf..55344c3bce 100644 --- a/tests/PostgresSchemaDump.hs +++ b/tests/PostgresSchemaDump.hs @@ -9,8 +9,7 @@ import Control.Concurrent (threadDelay) import Control.DeepSeq import Control.Monad (unless, void) import qualified Data.ByteString.Char8 as B -import Data.List (dropWhileEnd) -import Data.Maybe (fromJust, isJust) +import Data.Maybe (isNothing) import Simplex.Messaging.Agent.Store.Postgres (closeDBStore, createDBStore) import Simplex.Messaging.Agent.Store.Postgres.Common (DBOpts (..)) import qualified Simplex.Messaging.Agent.Store.Postgres.Migrations as Migrations @@ -36,7 +35,7 @@ postgresSchemaDumpTest migrations testDBOpts@DBOpts {connstr, schema = testDBSch getSchema srcSchemaPath `shouldReturn` savedSchema testSchemaMigrations = do - let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) migrations + let noDownMigrations = takeWhile (\Migration {down} -> isNothing down) migrations st <- createDBStore testDBOpts noDownMigrations (MigrationConfig MCYesUpDown Nothing) >>= \case Right st -> pure st Left e -> error $ show e @@ -44,20 +43,22 @@ postgresSchemaDumpTest migrations testDBOpts@DBOpts {connstr, schema = testDBSch closeDBStore st whenM (doesFileExist testSchemaPath) $ removeFile testSchemaPath where - testDownMigration st m = do - putStrLn $ "down migration " <> name m - let downMigr = fromJust $ toDownMigration m - schema <- getSchema testSchemaPath - Migrations.run st Nothing $ MTRUp [m] - schema' <- getSchema testSchemaPath - schema' `shouldNotBe` schema - Migrations.run st Nothing $ MTRDown [downMigr] - unless (name m `elem` skipComparisonForDownMigrations) $ do - schema'' <- getSchema testSchemaPath - schema'' `shouldBe` schema - Migrations.run st Nothing $ MTRUp [m] - schema''' <- getSchema testSchemaPath - schema''' `shouldBe` schema' + testDownMigration st m = case toDownMigration m of + -- a migration with no reverse step is applied, there is nothing to test + Nothing -> Migrations.run st Nothing $ MTRUp [m] + Just downMigr -> do + putStrLn $ "down migration " <> name m + schema <- getSchema testSchemaPath + Migrations.run st Nothing $ MTRUp [m] + schema' <- getSchema testSchemaPath + schema' `shouldNotBe` schema + Migrations.run st Nothing $ MTRDown [downMigr] + unless (name m `elem` skipComparisonForDownMigrations) $ do + schema'' <- getSchema testSchemaPath + schema'' `shouldBe` schema + Migrations.run st Nothing $ MTRUp [m] + schema''' <- getSchema testSchemaPath + schema''' `shouldBe` schema' getSchema :: FilePath -> IO String getSchema schemaPath = do diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 783f6587bf..51e8eb4221 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -11,9 +11,9 @@ import Control.Concurrent.STM import Control.DeepSeq import qualified Control.Exception as E import Control.Monad (unless, void) -import Data.List (dropWhileEnd, sort) +import Data.List (sort) import qualified Data.Map.Strict as M -import Data.Maybe (fromJust, isJust) +import Data.Maybe (isNothing) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.IO as T @@ -79,28 +79,30 @@ testVerifyLintFKeyIndexes = withTmpFiles $ do testSchemaMigrations :: IO () testSchemaMigrations = withTmpFiles $ do - let noDownMigrations = dropWhileEnd (\Migration {down} -> isJust down) Store.migrations + let noDownMigrations = takeWhile (\Migration {down} -> isNothing down) Store.migrations Right st <- createDBStore (DBOpts testDB chatDBFunctions "" False True TQOff) noDownMigrations (MigrationConfig MCError Nothing) mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations closeDBStore st removeFile testDB whenM (doesFileExist testSchema) $ removeFile testSchema where - testDownMigration st m = do - putStrLn $ "down migration " <> name m - let downMigr = fromJust $ toDownMigration m - schema <- getSchema testDB testSchema - Migrations.run st Nothing True $ MTRUp [m] - schema' <- getSchema testDB testSchema - unless (name m `elem` skipComparisonForUpMigrations) $ - schema' `shouldNotBe` schema - Migrations.run st Nothing True $ MTRDown [downMigr] - unless (name m `elem` skipComparisonForDownMigrations) $ do - schema'' <- getSchema testDB testSchema - schema'' `shouldBe` schema - Migrations.run st Nothing True $ MTRUp [m] - schema''' <- getSchema testDB testSchema - schema''' `shouldBe` schema' + testDownMigration st m = case toDownMigration m of + -- a migration with no reverse step is applied, there is nothing to test + Nothing -> Migrations.run st Nothing True $ MTRUp [m] + Just downMigr -> do + putStrLn $ "down migration " <> name m + schema <- getSchema testDB testSchema + Migrations.run st Nothing True $ MTRUp [m] + schema' <- getSchema testDB testSchema + unless (name m `elem` skipComparisonForUpMigrations) $ + schema' `shouldNotBe` schema + Migrations.run st Nothing True $ MTRDown [downMigr] + unless (name m `elem` skipComparisonForDownMigrations) $ do + schema'' <- getSchema testDB testSchema + schema'' `shouldBe` schema + Migrations.run st Nothing True $ MTRUp [m] + schema''' <- getSchema testDB testSchema + schema''' `shouldBe` schema' testVerifyStrict :: IO () testVerifyStrict = do diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index f42f7212dd..6c03e95f80 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -10,10 +10,11 @@ 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.Char (toUpper) 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 Simplex.Chat.Wallet (AccountIndex, WalletError (..), accountSecret, deriveAccountKey, entropyFromMnemonic, renderAccountPath, seedMaster, seedMnemonic) import qualified Simplex.Messaging.Crypto.BIP39 as B39 import qualified Simplex.Messaging.Crypto.Secp256k1 as S import Simplex.Messaging.Eth.Address (addressFromPrivateKey) @@ -32,27 +33,38 @@ testPhrase12 = B.unwords $ replicate 11 "abandon" <> ["about"] testPhrase24 :: ByteString testPhrase24 = B.unwords $ replicate 23 "abandon" <> ["art"] -seedFromPhrase :: ByteString -> WalletSeed -seedFromPhrase phrase = - WalletSeed {wsId = 1, wsEntropy = BA.convert . B39.mnemonicToEntropy . either error id $ B39.parseMnemonic phrase} +seedEntropy :: ByteString -> BA.ScrubbedBytes +seedEntropy phrase = 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 +accountKey :: BA.ScrubbedBytes -> AccountIndex -> S.PrivateKey +accountKey entropy n = either (error . show) id $ seedMaster entropy >>= \m -> deriveAccountKey m n + +-- | 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) + +-- | An @export account@ row: the index, the path, the address, the secret. +exportRow :: HasCallStack => String -> (String, String, String, String) +exportRow row = case words row of + [idx, path, address, secret] -> (idx, path, address, secret) + _ -> error $ "unexpected export row: " <> row walletDerivationTests :: Spec walletDerivationTests = do Hspec.it "accounts are the accounts another wallet derives for the same phrase" $ do - let addrOf = show . addressFromPrivateKey . accountKey (seedFromPhrase testPhrase12) + let addrOf = show . addressFromPrivateKey . accountKey (seedEntropy 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 + let k = accountKey (seedEntropy 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] + let entropy = seedEntropy testPhrase12 + addrs = map (show . addressFromPrivateKey . accountKey entropy) [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') @@ -63,74 +75,23 @@ walletDerivationTests = 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" $ - seedMnemonic (seedFromPhrase testPhrase24) `shouldBe` Right (safeDecodeUtf8 testPhrase24) + seedMnemonic (seedEntropy testPhrase24) `shouldBe` Right (safeDecodeUtf8 testPhrase24) Hspec.it "takes 24 words only, with a valid checksum" $ do entropyFromMnemonic testPhrase24 `shouldSatisfy` isRight entropyFromMnemonic testPhrase12 `shouldBe` Left WEBadMnemonic entropyFromMnemonic (B.unwords $ replicate 24 "abandon") `shouldBe` Left WEBadMnemonic -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 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 "leaves a deleted profile's account for another profile to take" testWalletDeletedProfileAccount 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 "the wallet, the accounts and the counter come back after a restart" testWalletPersists it "deletes the wallet, and one can be made again" testWalletDelete it "a hidden profile is bound no account" testWalletHiddenProfile it "will not export an account another profile holds" testWalletExportNotMine @@ -145,6 +106,10 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice <## "no wallet on this device" alice ##> "/_wallet export master" alice <## "wallet: this device has no wallet" + alice ##> "/_wallet bind" + alice <## "wallet: this device has no wallet" + alice ##> "/_wallet delete" + alice <## "wallet: this device has no wallet" alice ##> "/_wallet create new" alice <## "wallet, no accounts for this profile" alice ##> "/_wallet create new" @@ -154,6 +119,11 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do -- 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" + -- a phrase is taken as a backup card writes it, case and all + alice ##> ("/_wallet create mnemonic=" <> map toUpper (B.unpack testPhrase24)) + alice <## "wallet, no accounts for this profile" + alice ##> "/_wallet export master" + alice <## B.unpack testPhrase24 testWalletBind :: HasCallStack => TestParams -> IO () testWalletBind ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -200,6 +170,23 @@ testWalletBindByIndexThenNext ps = withNewTestChat ps "alice" aliceProfile $ \al alice ##> "/_wallet bind" alice <## "accounts: 1, 2, 3, 4" +testWalletDeletedProfileAccount :: HasCallStack => TestParams -> IO () +testWalletDeletedProfileAccount 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" + -- deleting a profile does not take its account with it + alice ##> "/delete user alice" + alice <### ["ok", "completed deleting user"] + alice ##> "/_wallet" + alice <## "wallet, no accounts for this profile" + -- and another profile can take it, which is how a name outlives its profile + alice ##> "/_wallet bind account=0" + alice <## "accounts: 0" + testWalletAddress :: HasCallStack => TestParams -> IO () testWalletAddress ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create new" @@ -219,9 +206,6 @@ testWalletAddress ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do 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 @@ -230,16 +214,23 @@ testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do 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 + (idx, path, address, secret) <- exportRow <$> getTermLine alice + 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 + -- the index reaches the key, not only the path printed beside it + alice ##> "/_wallet export account 1" + (idx', path', address', _) <- exportRow <$> getTermLine alice + idx' `shouldBe` "1" + path' `shouldBe` "m/44'/60'/1'/0/0" + address' `shouldBe` show (addressFromPrivateKey $ accountKey (seedEntropy testPhrase24) 1) + -- and an address is read from the account the command names, not the counter + alice ##> "/_wallet address account=1" + addressRow <- words <$> getTermLine alice + addressRow `shouldBe` ["1", "m/44'/60'/1'/0/0", address'] testWalletImport :: HasCallStack => TestParams -> IO () testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -272,6 +263,9 @@ testWalletPersists ps = do alice <## "accounts: 2" alice ##> "/_wallet export master" alice <## phrase + -- the counter came back too, so no account is handed out a second time + alice ##> "/_wallet bind" + alice <## "accounts: 2, 3" testWalletDelete :: HasCallStack => TestParams -> IO () testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -283,6 +277,58 @@ testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice <## "ok" alice ##> "/_wallet" 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" + -- the new wallet holds no account and its counter starts over + alice ##> "/_wallet bind" + alice <## "accounts: 0" + +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" + -- the profile's own account is its to export + alice ##> "/_wallet export account 0" + (_, path, _, _) <- exportRow <$> getTermLine alice + path `shouldBe` "m/44'/60'/0'/0/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" + (_, path', _, _) <- exportRow <$> getTermLine alice + path' `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" From ac989735984c26b0d63af49de7d4bb00c92fb3e9 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 21 Sep 2026 17:24:57 +0200 Subject: [PATCH 45/67] repin --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index 2c78a49b11..0e00d2dbd9 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: e6597f28b0bc7ed93b75635890b139435e6427f6 + tag: 33e8ef018e8080bc8134ea8bd5ad20b3b2afe987 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 225b67b12b..1067ae92ec 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."e6597f28b0bc7ed93b75635890b139435e6427f6" = "0pkmj6g85jjx2wcxbfh6p2zp4r9r3zcb6dry3j1mxazm7pkfs5rs"; + "https://github.com/simplex-chat/simplexmq.git"."33e8ef018e8080bc8134ea8bd5ad20b3b2afe987" = "0n0d0hdk8zs49wxvbyb2mklv8lwgjnm2gdanbh5z00dgm6m7rdby"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; From 1c5ed7275d8db56b1fa67d5e5e564c28b8a1431d Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 21 Sep 2026 17:25:35 +0200 Subject: [PATCH 46/67] rename to adapt --- src/Simplex/Chat/Wallet.hs | 2 +- tests/WalletTests.hs | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index 7ecad22bfc..f0e84884a3 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -45,7 +45,7 @@ import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) type AccountIndex = Word32 -- | The key at an account index. It owns whatever that account owns. -type AccountKey = S.PrivateKey +type AccountKey = S.Secp256k1PrivateKey -- | One derived address, with the index it came from, so a caller that left the -- index out knows what it got. diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 6c03e95f80..1e8426024a 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -36,7 +36,7 @@ testPhrase24 = B.unwords $ replicate 23 "abandon" <> ["art"] seedEntropy :: ByteString -> BA.ScrubbedBytes seedEntropy phrase = BA.convert . B39.mnemonicToEntropy . either error id $ B39.parseMnemonic phrase -accountKey :: BA.ScrubbedBytes -> AccountIndex -> S.PrivateKey +accountKey :: BA.ScrubbedBytes -> AccountIndex -> S.Secp256k1PrivateKey accountKey entropy n = either (error . show) id $ seedMaster entropy >>= \m -> deriveAccountKey m n -- | The address a wallet reaches when the secret is imported as a private key. From faa96e3da74c5368190b7d33f043e93ae066ad64 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 21 Sep 2026 17:39:38 +0200 Subject: [PATCH 47/67] cut comments --- src/Simplex/Chat/Library/Commands.hs | 8 +--- src/Simplex/Chat/Remote.hs | 3 +- .../Migrations/M20260921_wallet_seeds.hs | 5 +-- src/Simplex/Chat/Store/Wallets.hs | 40 +++++-------------- src/Simplex/Chat/Wallet.hs | 28 +++---------- tests/WalletTests.hs | 10 ++--- 6 files changed, 23 insertions(+), 71 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 11d03a5ff9..3deb592a8e 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1503,8 +1503,7 @@ processChatCommand cxt nm = \case 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 + -- a generated seed has taken no accounts, an imported one does not say how many it has taken (entropy, nextAccount) <- case mnemonic_ of Nothing -> (,Just 0) <$> (asks random >>= atomically . newSeedEntropy) Just phrase -> (,Nothing) <$> liftWallet (entropyFromMnemonic $ encodeUtf8 phrase) @@ -6644,10 +6643,7 @@ chatCommandP = quotedP = safeDecodeUtf8 <$> (A.char '"' *> A.takeTill (== '"') <* A.char '"') text1P = safeDecodeUtf8 <$> A.takeTill (== ' ') char_ = optional . A.char - -- The digits are counted before they are read, as reading a very long - -- number is not free. Ten of them fit an Int with room to spare, and the - -- hardening bound is a typed error when the command runs, so that a caller - -- is told which index was refused and why. + -- ten digits fit an Int, and the hardening bound is a typed error when the command runs accountIndexP = do ds <- A.takeWhile1 isDigit case if B.length ds <= 10 then B.readInt ds else Nothing of diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index de1b812b87..7be5cd1707 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -552,8 +552,7 @@ liftRC = liftError (ChatErrorRemoteCtrl . RCEProtocolError) handleSend :: (ByteString -> Int -> CM' (Either ChatError ChatResponse)) -> Text -> Int -> CM' RemoteResponse handleSend execCC command retryNum = do - -- only the verb: the rest of the line can carry a mnemonic, and blanking it by - -- substring was guesswork + -- only the verb: the rest of the line can carry a mnemonic 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 diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260921_wallet_seeds.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260921_wallet_seeds.hs index 0f0054f334..014c01894b 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260921_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260921_wallet_seeds.hs @@ -27,7 +27,4 @@ CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_a CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id); |] --- There is no reverse step. Reversing this would drop the only copy of the --- master entropy, and a downgrade that takes every key on the device with it is --- worse than one that refuses: without a reverse step the older app reports that --- the database is newer than it is and changes nothing. +-- No reverse step: it would drop the only copy of the master entropy. diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index ef39533c26..b804cf9f5c 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -40,9 +40,7 @@ import Database.SQLite.Simple.QQ (sql) type SeedId = Int64 --- | The device seed, as a row: the id the account rows are counted against, and --- the entropy every key is derived from. The entropy is 'BA.ScrubbedBytes', so --- a derived 'Show' does not print it. +-- | The device seed row. The entropy is 'BA.ScrubbedBytes', so a derived 'Show' does not print it. data WalletSeed = WalletSeed { wsId :: SeedId, wsEntropy :: BA.ScrubbedBytes @@ -57,8 +55,7 @@ getWalletSeed db = maybeFirstRow toSeed $ DB.query_ db "SELECT wallet_seed_id, entropy FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1" --- | 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. +-- | False if the device already has a seed. The counter is 'Nothing' for an imported phrase. createWalletSeed :: DB.Connection -> BA.ScrubbedBytes -> Maybe AccountIndex -> IO Bool createWalletSeed db entropy nextAccount = getWalletSeed db >>= \case @@ -78,10 +75,7 @@ deleteWalletSeed db = Just WalletSeed {wsId} -> True <$ DB.execute db "DELETE FROM wallet_seeds WHERE wallet_seed_id = ?" (Only wsId) --- | The seed, and the account an argument names or the next free one from the --- counter when it names none. Refuses an index BIP-32 cannot harden, the --- counter's included. The seed comes back with it so that a caller derives from --- the one the index was resolved against. +-- | The seed, and the account named or the next free one from the counter. Refuses an index BIP-32 cannot harden. resolveAccount :: DB.Connection -> Maybe AccountIndex -> IO (Either WalletError (WalletSeed, AccountIndex)) resolveAccount db accountIdx_ = runExceptT $ do seed <- ExceptT $ maybe (Left WENoMaster) Right <$> getWalletSeed db @@ -91,15 +85,13 @@ resolveAccount db accountIdx_ = runExceptT $ do where nextFreeAccount sId = ExceptT $ maybe (Left WECounterUnknown) Right <$> getNextAccountIndex db 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. +-- | The index the next account takes. Nothing after an import. 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. +-- | The accounts a profile holds, in index order. getUserAccounts :: DB.Connection -> SeedId -> UserId -> IO [AccountIndex] getUserAccounts db sId userId = map (fromIntegral @Int64 . fromOnly) @@ -112,17 +104,13 @@ getUserAccounts db sId userId = |] (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. +-- | Which profile holds an account: 'Nothing' when it is unknown, @Just Nothing@ when 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 keeps one --- profile from handing out another's account key. It is a guard, not a --- boundary: @export master@ reaches every account from any profile. An account --- no profile holds is not another profile's. +-- | True when another profile holds the account. A guard, not a boundary: @export master@ reaches every account. heldByOther :: UserId -> Maybe (Maybe Int64) -> Bool heldByOther userId = \case Just (Just heldBy) -> heldBy /= userId @@ -131,10 +119,7 @@ heldByOther userId = \case 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. The whole command is --- one transaction, so nothing between reading the counter and taking the --- account can hand the same one out twice. +-- | Bind an account to a profile, the next free one when no index is given. One transaction, so none is taken twice. bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> IO (Either WalletError ()) bindAccount db userId accountIdx_ = runExceptT $ do (WalletSeed {wsId = sId}, n) <- ExceptT $ resolveAccount db accountIdx_ @@ -142,9 +127,7 @@ bindAccount db userId accountIdx_ = runExceptT $ do when (heldByOther userId held) $ throwError WEAccountBound taken <- liftIO $ case held of Just (Just _) -> pure True -- already this profile's - -- the update takes the account only while no profile holds it, and the read - -- after it says whether this one got it: where transactions are not - -- serialised, two of them can both read the account as free + -- the update takes the account only while no profile holds it, the read after says whether this one got it Just Nothing -> setAccountUser db sId userId n >> accountHeldBy db sId userId n Nothing -> True <$ insertAccount db sId userId n unless taken $ throwError WEAccountBound @@ -163,9 +146,7 @@ setAccountUser db sId userId n = accountHeldBy :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bool accountHeldBy db sId userId n = (== Just (Just userId)) <$> accountUser db sId 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. +-- | Keep the counter a high-water mark. Never lowers it, never gives one to an imported phrase that has none. raiseNextAccount :: DB.Connection -> SeedId -> AccountIndex -> IO () raiseNextAccount db sId n = DB.execute @@ -180,6 +161,5 @@ 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/Wallet.hs b/src/Simplex/Chat/Wallet.hs index f0e84884a3..2e7fef8a33 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -1,14 +1,7 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} --- | The device wallet: one BIP-39 seed, and the accounts derived from it. --- --- 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". +-- | The device wallet: one BIP-39 seed, and the hardened BIP-44 accounts @m\/44'\/60'\/n'\/0\/0@ under it. module Simplex.Chat.Wallet ( AccountIndex, AccountKey, @@ -40,15 +33,12 @@ import qualified Simplex.Messaging.Crypto.Secp256k1 as S import Simplex.Messaging.Eth.Address (ethereumPath) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) --- | BIP-44 account index. One account owns one thing on chain, one name to --- begin with. +-- | BIP-44 account index, one per thing the device owns on chain. type AccountIndex = Word32 --- | The key at an account index. It owns whatever that account owns. type AccountKey = S.Secp256k1PrivateKey --- | One derived address, with the index it came from, so a caller that left the --- index out knows what it got. +-- | One derived address, with the index it came from. data WalletAddress = WalletAddress { accountIndex :: AccountIndex, keyPath :: Text, @@ -67,14 +57,11 @@ data WalletError | WEDerivation {derivationError :: String} -- BIP-32 or BIP-39 said no deriving (Eq, Show) --- | 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. +-- | Refuse an index at or above 2^31: BIP-32 would harden it onto another index's key. checkAccountIndex :: AccountIndex -> Either WalletError () checkAccountIndex n = if n >= B32.hardenedOffset then Left WEIndexTooLarge else Right () --- | 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. +-- | 24 words. No 25th-word passphrase, which would be a second secret to back up. masterStrength :: B39.MnemonicStrength masterStrength = B39.MS256 @@ -114,10 +101,7 @@ accountSecret k = "0x" <> decodeLatin1 (BAE.convertToBase BAE.Base16 $ S.unPriva entropyBytes :: BA.ScrubbedBytes -> ByteString entropyBytes = BA.convert --- | 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. +-- | BIP-32 and BIP-39 report failure as a string, and nothing here retries, so one constructor covers them. bipError :: Either String a -> Either WalletError a bipError = either (Left . WEDerivation) Right diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 1e8426024a..4f609591af 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -22,14 +22,11 @@ import Simplex.Messaging.Util (safeDecodeUtf8) import Test.Hspec hiding (it) import qualified Test.Hspec as Hspec --- | 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. +-- | The standard BIP-39 test vector, 12 words, to check the addresses against another wallet. testPhrase12 :: ByteString testPhrase12 = B.unwords $ replicate 11 "abandon" <> ["about"] --- | 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. +-- | The 24 word all-zero-entropy vector, the length the commands take. testPhrase24 :: ByteString testPhrase24 = B.unwords $ replicate 23 "abandon" <> ["art"] @@ -217,8 +214,7 @@ testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do (idx, path, address, secret) <- exportRow <$> getTermLine alice 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 + -- m/44'/60'/0'/0/0 of the 24 word vector, pinned so a change of path fails here address `shouldBe` "0xF278cF59F82eDcf871d630F28EcC8056f25C1cdb" addressFromSecret secret `shouldBe` address -- the index reaches the key, not only the path printed beside it From 6b908c87e037bf436272f7a988c8550e208084fd Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 22 Sep 2026 08:01:01 +0200 Subject: [PATCH 48/67] next review round --- src/Simplex/Chat/Library/Commands.hs | 11 ++- .../Store/Postgres/Migrations/chat_schema.sql | 75 +++++++++++++++++++ .../Store/SQLite/Migrations/chat_schema.sql | 18 +++++ src/Simplex/Chat/Store/Wallets.hs | 8 +- tests/WalletTests.hs | 3 + 5 files changed, 104 insertions(+), 11 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 3deb592a8e..dceee7b794 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -66,7 +66,7 @@ import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Badges.Service (BadgeBalance (..), BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), BadgeStatement (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..), currentBadgeServiceVersion) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) import Simplex.Chat.Store.Wallets (WalletSeed (..), accountHeldByOther, bindAccount, createWalletSeed, deleteWalletSeed, getUserAccounts, getWalletSeed, resolveAccount) -import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress (..), WalletError (..), accountSecret, checkAccountIndex, deriveAccountKey, entropyFromMnemonic, newSeedEntropy, renderAccountPath, seedMaster, seedMnemonic) +import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress (..), WalletError (..), accountSecret, deriveAccountKey, entropyFromMnemonic, newSeedEntropy, renderAccountPath, seedMaster, seedMnemonic) import Simplex.Messaging.Eth.Address (addressFromPrivateKey) import Simplex.Chat.Call import Simplex.Chat.Controller @@ -1520,8 +1520,7 @@ processChatCommand cxt nm = \case APIExportWalletMnemonic -> withUser $ \user -> CRWalletMnemonic user <$> (liftWallet . seedMnemonic . wsEntropy =<< walletSeed) APIExportWalletAccount n -> withUser $ \user@User {userId} -> do - seed <- walletSeed - liftWallet $ checkAccountIndex n + (seed, _) <- liftWallet =<< withFastStore' (`resolveAccount` Just n) -- 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 @@ -6643,11 +6642,11 @@ chatCommandP = quotedP = safeDecodeUtf8 <$> (A.char '"' *> A.takeTill (== '"') <* A.char '"') text1P = safeDecodeUtf8 <$> A.takeTill (== ' ') char_ = optional . A.char - -- ten digits fit an Int, and the hardening bound is a typed error when the command runs + -- a long digit run is not free to convert; the hardening bound is checked when the command runs accountIndexP = do ds <- A.takeWhile1 isDigit - case if B.length ds <= 10 then B.readInt ds else Nothing of - Just (i, _) | i <= fromIntegral (maxBound :: AccountIndex) -> pure $ fromIntegral i + case if B.length ds <= 10 then B.readInteger ds else Nothing of + Just (i, _) | i <= toInteger (maxBound :: AccountIndex) -> pure (fromInteger i) _ -> fail "account index too large" displayNameP :: Parser Text diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 1e2a26d74e..c37ae7ed7e 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1724,6 +1724,49 @@ ALTER TABLE test_chat_schema.users ALTER COLUMN user_id ADD GENERATED ALWAYS AS +CREATE TABLE test_chat_schema.wallet_accounts ( + wallet_account_id bigint NOT NULL, + wallet_seed_id bigint NOT NULL, + account_index bigint, + user_id bigint, + CONSTRAINT wallet_accounts_account_index_check CHECK (((account_index >= 0) AND (account_index <= 2147483647))) +); + + + +ALTER TABLE test_chat_schema.wallet_accounts ALTER COLUMN wallet_account_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.wallet_accounts_wallet_account_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + +CREATE TABLE test_chat_schema.wallet_seeds ( + wallet_seed_id bigint NOT NULL, + entropy bytea NOT NULL, + next_account_index bigint, + single_seed smallint DEFAULT 1 NOT NULL, + CONSTRAINT wallet_seeds_entropy_check CHECK ((length(entropy) = 32)), + CONSTRAINT wallet_seeds_next_account_index_check CHECK (((next_account_index >= 0) AND (next_account_index <= '2147483648'::bigint))) +); + + + +ALTER TABLE test_chat_schema.wallet_seeds ALTER COLUMN wallet_seed_id ADD GENERATED ALWAYS AS IDENTITY ( + SEQUENCE NAME test_chat_schema.wallet_seeds_wallet_seed_id_seq + START WITH 1 + INCREMENT BY 1 + NO MINVALUE + NO MAXVALUE + CACHE 1 +); + + + CREATE TABLE test_chat_schema.xftp_file_descriptions ( file_descr_id bigint NOT NULL, user_id bigint NOT NULL, @@ -2137,6 +2180,16 @@ ALTER TABLE ONLY test_chat_schema.users +ALTER TABLE ONLY test_chat_schema.wallet_accounts + ADD CONSTRAINT wallet_accounts_pkey PRIMARY KEY (wallet_account_id); + + + +ALTER TABLE ONLY test_chat_schema.wallet_seeds + ADD CONSTRAINT wallet_seeds_pkey PRIMARY KEY (wallet_seed_id); + + + ALTER TABLE ONLY test_chat_schema.xftp_file_descriptions ADD CONSTRAINT xftp_file_descriptions_pkey PRIMARY KEY (file_descr_id); @@ -2938,6 +2991,18 @@ CREATE INDEX idx_users_shown_badge ON test_chat_schema.users USING btree (shown_ +CREATE INDEX idx_wallet_accounts_user_id ON test_chat_schema.wallet_accounts USING btree (user_id); + + + +CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON test_chat_schema.wallet_accounts USING btree (wallet_seed_id, account_index); + + + +CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON test_chat_schema.wallet_seeds USING btree (single_seed); + + + CREATE INDEX idx_xftp_file_descriptions_user_id ON test_chat_schema.xftp_file_descriptions USING btree (user_id); @@ -3663,6 +3728,16 @@ ALTER TABLE ONLY test_chat_schema.users +ALTER TABLE ONLY test_chat_schema.wallet_accounts + ADD CONSTRAINT wallet_accounts_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE SET NULL; + + + +ALTER TABLE ONLY test_chat_schema.wallet_accounts + ADD CONSTRAINT wallet_accounts_wallet_seed_id_fkey FOREIGN KEY (wallet_seed_id) REFERENCES test_chat_schema.wallet_seeds(wallet_seed_id) ON DELETE CASCADE; + + + ALTER TABLE ONLY test_chat_schema.xftp_file_descriptions ADD CONSTRAINT xftp_file_descriptions_user_id_fkey FOREIGN KEY (user_id) REFERENCES test_chat_schema.users(user_id) ON DELETE CASCADE; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index 090b6332ab..d98ebb1534 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -975,6 +975,18 @@ CREATE TABLE badge_code_redemptions( created_at TEXT NOT NULL, UNIQUE(user_id, code) ) STRICT; +CREATE TABLE wallet_seeds( + wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, + 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 @@ -1538,6 +1550,12 @@ CREATE INDEX idx_badge_code_redemptions_user ON badge_code_redemptions( CREATE UNIQUE INDEX idx_badge_purchases_code_redemption ON badge_purchases( badge_code_redemption_id ); +CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON wallet_seeds(single_seed); +CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_accounts( + wallet_seed_id, + account_index +); +CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id); 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 b804cf9f5c..d6589b464c 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -19,7 +19,7 @@ module Simplex.Chat.Store.Wallets ) where -import Control.Monad (join, unless, when) +import Control.Monad (join, unless) import Control.Monad.Except import Control.Monad.IO.Class (liftIO) import qualified Data.ByteArray as BA @@ -123,10 +123,8 @@ accountHeldByOther db sId userId n = heldByOther userId <$> accountUser db sId n bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> IO (Either WalletError ()) bindAccount db userId accountIdx_ = runExceptT $ do (WalletSeed {wsId = sId}, n) <- ExceptT $ resolveAccount db accountIdx_ - held <- liftIO $ accountUser db sId n - when (heldByOther userId held) $ throwError WEAccountBound - taken <- liftIO $ case held of - Just (Just _) -> pure True -- already this profile's + taken <- liftIO $ accountUser db sId n >>= \case + Just (Just heldBy) -> pure $ heldBy == userId -- the update takes the account only while no profile holds it, the read after says whether this one got it Just Nothing -> setAccountUser db sId userId n >> accountHeldBy db sId userId n Nothing -> True <$ insertAccount db sId userId n diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 4f609591af..c134f6bb80 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -198,6 +198,9 @@ testWalletAddress ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet address account=3" at3 <- getTermLine alice words at3 !! 1 `shouldBe` "m/44'/60'/3'/0/0" + -- a zero-padded index is the same index + alice ##> "/_wallet address account=0000000003" + getTermLine alice `shouldReturn` at3 -- 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" From 7445e685b7766e560a3c77de0b55bf75342b46b3 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 22 Sep 2026 09:29:47 +0200 Subject: [PATCH 49/67] minimize diff and align style even more --- cabal.project | 2 +- docs/rfcs/2026-09-10-wallet-keys.md | 2 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat/Remote.hs | 3 +-- tests/PostgresSchemaDump.hs | 34 +++++++++++++-------------- tests/SchemaDump.hs | 36 ++++++++++++++--------------- 7 files changed, 38 insertions(+), 43 deletions(-) diff --git a/cabal.project b/cabal.project index 0e00d2dbd9..49f4cb95d0 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 33e8ef018e8080bc8134ea8bd5ad20b3b2afe987 + tag: 7dcd89d30bdf06913b03f3884bad987b75a6744a source-repository-package type: git diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 3d2a8a1626..c11d288d8c 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -149,7 +149,7 @@ A null `account_index` marks an account whose key was imported rather than deriv - **A wallet the master phrase is imported into.** Enumerating BIP-44 accounts computes account extended public keys, and some wallets send them to a vendor, which hands that vendor every account on the device at once, across every profile. That is what an account for each name otherwise prevents. - **Whoever answers the recovery scan.** Sees every address the phrase could hold a name on, in one burst, so it links every account on the device, across profiles, and recognises addresses that hold nothing yet, which is where future accounts will be. `address` derives for any index straight from the master, so a caller can enumerate hidden profiles' addresses too. This is the sharpest cost in the design. - **A paired device.** Can run any of these commands, because they are not blocked from one: `export master` reads the whole wallet, `create` on a device that has none plants a seed the pairing controls, and `delete` destroys the only copy. Blocking `ExecChatStoreSQL` while allowing `export master` is not a coherent line, and the wallet commands need their own decision rather than the catch-all. -- **Someone reading the logs.** 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. +- **Someone reading the logs.** A remote session logs the whole command line at debug level, so a phrase typed into `create` reaches the log there. Answers are never logged. Narrowing that line is work in the remote session rather than in the wallet. - **A page open in the user's browser.** The websocket server in `apps/simplex-chat/Server.hs` accepts any local connection, asks for no token and checks no `Origin`, and websockets are not bound by the same origin policy, so any page loaded while that server runs can send `export master` and read the answer. It also prints every command it receives, that phrase included. Both are properties of that server, which this change gives something worth taking, and closing them is work there rather than in the wallet. ## Known limits diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 1067ae92ec..69aca6ab3e 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."33e8ef018e8080bc8134ea8bd5ad20b3b2afe987" = "0n0d0hdk8zs49wxvbyb2mklv8lwgjnm2gdanbh5z00dgm6m7rdby"; + "https://github.com/simplex-chat/simplexmq.git"."7dcd89d30bdf06913b03f3884bad987b75a6744a" = "0357ak8sxdm6vh103gjdvd073w6lsa2yc8mv1iis937w270siwqn"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index 366b80917d..82dc113452 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -45,7 +45,6 @@ library Simplex.Chat.Badges.Service Simplex.Chat.Badges.Types Simplex.Chat.Names - Simplex.Chat.Store.Wallets Simplex.Chat.Wallet Simplex.Chat.Call Simplex.Chat.Controller @@ -96,6 +95,7 @@ library Simplex.Chat.Store.RelayRequests Simplex.Chat.Store.Remote Simplex.Chat.Store.Shared + Simplex.Chat.Store.Wallets Simplex.Chat.Styled Simplex.Chat.Types Simplex.Chat.Types.MemberRelations diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 7be5cd1707..4de4f4f6b7 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -552,8 +552,7 @@ liftRC = liftError (ChatErrorRemoteCtrl . RCEProtocolError) handleSend :: (ByteString -> Int -> CM' (Either ChatError ChatResponse)) -> Text -> Int -> CM' RemoteResponse handleSend execCC command retryNum = do - -- only the verb: the rest of the line can carry a mnemonic - logDebug $ "Send: " <> T.takeWhile (/= ' ') command + logDebug $ "Send: " <> tshow 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/tests/PostgresSchemaDump.hs b/tests/PostgresSchemaDump.hs index 55344c3bce..0c6305b52d 100644 --- a/tests/PostgresSchemaDump.hs +++ b/tests/PostgresSchemaDump.hs @@ -9,7 +9,7 @@ import Control.Concurrent (threadDelay) import Control.DeepSeq import Control.Monad (unless, void) import qualified Data.ByteString.Char8 as B -import Data.Maybe (isNothing) +import Data.Maybe (fromJust, isJust, isNothing) import Simplex.Messaging.Agent.Store.Postgres (closeDBStore, createDBStore) import Simplex.Messaging.Agent.Store.Postgres.Common (DBOpts (..)) import qualified Simplex.Messaging.Agent.Store.Postgres.Migrations as Migrations @@ -39,26 +39,24 @@ postgresSchemaDumpTest migrations testDBOpts@DBOpts {connstr, schema = testDBSch st <- createDBStore testDBOpts noDownMigrations (MigrationConfig MCYesUpDown Nothing) >>= \case Right st -> pure st Left e -> error $ show e - mapM_ (testDownMigration st) $ drop (length noDownMigrations) migrations + mapM_ (testDownMigration st) $ filter (isJust . down) $ drop (length noDownMigrations) migrations closeDBStore st whenM (doesFileExist testSchemaPath) $ removeFile testSchemaPath where - testDownMigration st m = case toDownMigration m of - -- a migration with no reverse step is applied, there is nothing to test - Nothing -> Migrations.run st Nothing $ MTRUp [m] - Just downMigr -> do - putStrLn $ "down migration " <> name m - schema <- getSchema testSchemaPath - Migrations.run st Nothing $ MTRUp [m] - schema' <- getSchema testSchemaPath - schema' `shouldNotBe` schema - Migrations.run st Nothing $ MTRDown [downMigr] - unless (name m `elem` skipComparisonForDownMigrations) $ do - schema'' <- getSchema testSchemaPath - schema'' `shouldBe` schema - Migrations.run st Nothing $ MTRUp [m] - schema''' <- getSchema testSchemaPath - schema''' `shouldBe` schema' + testDownMigration st m = do + putStrLn $ "down migration " <> name m + let downMigr = fromJust $ toDownMigration m + schema <- getSchema testSchemaPath + Migrations.run st Nothing $ MTRUp [m] + schema' <- getSchema testSchemaPath + schema' `shouldNotBe` schema + Migrations.run st Nothing $ MTRDown [downMigr] + unless (name m `elem` skipComparisonForDownMigrations) $ do + schema'' <- getSchema testSchemaPath + schema'' `shouldBe` schema + Migrations.run st Nothing $ MTRUp [m] + schema''' <- getSchema testSchemaPath + schema''' `shouldBe` schema' getSchema :: FilePath -> IO String getSchema schemaPath = do diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 51e8eb4221..814183b15b 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -13,7 +13,7 @@ import qualified Control.Exception as E import Control.Monad (unless, void) import Data.List (sort) import qualified Data.Map.Strict as M -import Data.Maybe (isNothing) +import Data.Maybe (fromJust, isJust, isNothing) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.IO as T @@ -81,28 +81,26 @@ testSchemaMigrations :: IO () testSchemaMigrations = withTmpFiles $ do let noDownMigrations = takeWhile (\Migration {down} -> isNothing down) Store.migrations Right st <- createDBStore (DBOpts testDB chatDBFunctions "" False True TQOff) noDownMigrations (MigrationConfig MCError Nothing) - mapM_ (testDownMigration st) $ drop (length noDownMigrations) Store.migrations + mapM_ (testDownMigration st) $ filter (isJust . down) $ drop (length noDownMigrations) Store.migrations closeDBStore st removeFile testDB whenM (doesFileExist testSchema) $ removeFile testSchema where - testDownMigration st m = case toDownMigration m of - -- a migration with no reverse step is applied, there is nothing to test - Nothing -> Migrations.run st Nothing True $ MTRUp [m] - Just downMigr -> do - putStrLn $ "down migration " <> name m - schema <- getSchema testDB testSchema - Migrations.run st Nothing True $ MTRUp [m] - schema' <- getSchema testDB testSchema - unless (name m `elem` skipComparisonForUpMigrations) $ - schema' `shouldNotBe` schema - Migrations.run st Nothing True $ MTRDown [downMigr] - unless (name m `elem` skipComparisonForDownMigrations) $ do - schema'' <- getSchema testDB testSchema - schema'' `shouldBe` schema - Migrations.run st Nothing True $ MTRUp [m] - schema''' <- getSchema testDB testSchema - schema''' `shouldBe` schema' + testDownMigration st m = do + putStrLn $ "down migration " <> name m + let downMigr = fromJust $ toDownMigration m + schema <- getSchema testDB testSchema + Migrations.run st Nothing True $ MTRUp [m] + schema' <- getSchema testDB testSchema + unless (name m `elem` skipComparisonForUpMigrations) $ + schema' `shouldNotBe` schema + Migrations.run st Nothing True $ MTRDown [downMigr] + unless (name m `elem` skipComparisonForDownMigrations) $ do + schema'' <- getSchema testDB testSchema + schema'' `shouldBe` schema + Migrations.run st Nothing True $ MTRUp [m] + schema''' <- getSchema testDB testSchema + schema''' `shouldBe` schema' testVerifyStrict :: IO () testVerifyStrict = do From 4d4a3ce42d24e71e34d8c24983e67eb023c02cbd Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Tue, 22 Sep 2026 15:28:36 +0200 Subject: [PATCH 50/67] missing type mappings --- bots/api/TYPES.md | 37 +++++++++++ .../types/typescript/src/types.ts | 66 +++++++++++++++++++ .../src/simplex_chat/types/_types.py | 45 ++++++++++++- 3 files changed, 147 insertions(+), 1 deletion(-) diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index f1122979a9..82db41b533 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -219,6 +219,7 @@ This file is generated automatically. - [UserProfileUpdateSummary](#userprofileupdatesummary) - [UserPwdHash](#userpwdhash) - [VersionRange](#versionrange) +- [WalletError](#walleterror) - [XFTPErrorType](#xftperrortype) - [XFTPRcvFile](#xftprcvfile) - [XFTPSndFile](#xftpsndfile) @@ -1231,6 +1232,10 @@ SimplexDomainNotReady: - simplexDomain: [SimplexDomain](#simplexdomain) - simplexDomainError: [SimplexDomainError](#simplexdomainerror) +Wallet: +- type: "wallet" +- walletError: [WalletError](#walleterror) + NotResolvedLocally: - type: "notResolvedLocally" @@ -4633,6 +4638,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/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index d7db7f259f..5ec1b205b0 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -1180,6 +1180,7 @@ export type ChatErrorType = | ChatErrorType.ChatStoreChanged | ChatErrorType.InvalidConnReq | ChatErrorType.SimplexDomainNotReady + | ChatErrorType.Wallet | ChatErrorType.NotResolvedLocally | ChatErrorType.UnsupportedConnReq | ChatErrorType.ConnReqMessageProhibited @@ -1260,6 +1261,7 @@ export namespace ChatErrorType { | "chatStoreChanged" | "invalidConnReq" | "simplexDomainNotReady" + | "wallet" | "notResolvedLocally" | "unsupportedConnReq" | "connReqMessageProhibited" @@ -1422,6 +1424,11 @@ export namespace ChatErrorType { simplexDomainError: SimplexDomainError } + export interface Wallet extends Interface { + type: "wallet" + walletError: WalletError + } + export interface NotResolvedLocally extends Interface { type: "notResolvedLocally" } @@ -5300,6 +5307,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 ab06a24f53..b853ae196e 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -883,6 +883,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"] @@ -1121,6 +1125,7 @@ ChatErrorType = ( | ChatErrorType_chatStoreChanged | ChatErrorType_invalidConnReq | ChatErrorType_simplexDomainNotReady + | ChatErrorType_wallet | ChatErrorType_notResolvedLocally | ChatErrorType_unsupportedConnReq | ChatErrorType_connReqMessageProhibited @@ -1179,7 +1184,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", "badgeRedeemError", "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", "badgeRedeemError", "agentCommandError", "invalidFileDescription", "connectionIncognitoChangeProhibited", "connectionUserChangeProhibited", "peerChatVRangeIncompatible", "relayTestError", "internalError", "exception"] ChatFeature = Literal["timedMessages", "fullDelete", "reactions", "voice", "files", "calls", "sessions"] @@ -3717,6 +3722,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"] From 04b1992f942ca1db0c50b737eb282078b851a4ba Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 24 Sep 2026 10:14:37 +0200 Subject: [PATCH 51/67] fix review comments --- cabal.project | 2 +- docs/rfcs/2026-09-10-wallet-keys.md | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Library/Commands.hs | 27 +++++++++++++--------- src/Simplex/Chat/Remote.hs | 1 - src/Simplex/Chat/Store/Wallets.hs | 24 +++++++++++--------- src/Simplex/Chat/Wallet.hs | 13 ++++++----- tests/WalletTests.hs | 34 ++++++++++++++++------------ 8 files changed, 59 insertions(+), 46 deletions(-) diff --git a/cabal.project b/cabal.project index 49f4cb95d0..1b078d29df 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 7dcd89d30bdf06913b03f3884bad987b75a6744a + tag: 3af92e4a5d8069d376b4ba68be564786c7829c24 source-repository-package type: git diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index c11d288d8c..2b6f5e4644 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -149,7 +149,7 @@ A null `account_index` marks an account whose key was imported rather than deriv - **A wallet the master phrase is imported into.** Enumerating BIP-44 accounts computes account extended public keys, and some wallets send them to a vendor, which hands that vendor every account on the device at once, across every profile. That is what an account for each name otherwise prevents. - **Whoever answers the recovery scan.** Sees every address the phrase could hold a name on, in one burst, so it links every account on the device, across profiles, and recognises addresses that hold nothing yet, which is where future accounts will be. `address` derives for any index straight from the master, so a caller can enumerate hidden profiles' addresses too. This is the sharpest cost in the design. - **A paired device.** Can run any of these commands, because they are not blocked from one: `export master` reads the whole wallet, `create` on a device that has none plants a seed the pairing controls, and `delete` destroys the only copy. Blocking `ExecChatStoreSQL` while allowing `export master` is not a coherent line, and the wallet commands need their own decision rather than the catch-all. -- **Someone reading the logs.** A remote session logs the whole command line at debug level, so a phrase typed into `create` reaches the log there. Answers are never logged. Narrowing that line is work in the remote session rather than in the wallet. +- **Someone reading the logs.** The core logs no command and no answer, so a phrase typed into `create` reaches no log. - **A page open in the user's browser.** The websocket server in `apps/simplex-chat/Server.hs` accepts any local connection, asks for no token and checks no `Origin`, and websockets are not bound by the same origin policy, so any page loaded while that server runs can send `export master` and read the answer. It also prints every command it receives, that phrase included. Both are properties of that server, which this change gives something worth taking, and closing them is work there rather than in the wallet. ## Known limits diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 69aca6ab3e..958d1b248e 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."7dcd89d30bdf06913b03f3884bad987b75a6744a" = "0357ak8sxdm6vh103gjdvd073w6lsa2yc8mv1iis937w270siwqn"; + "https://github.com/simplex-chat/simplexmq.git"."3af92e4a5d8069d376b4ba68be564786c7829c24" = "1d3s1ng1wfz1j4cpml9ky09fkj0l55cpfbh0mlxm3m211rlbb7mk"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index dceee7b794..b615d06ae6 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1502,21 +1502,22 @@ processChatCommand cxt nm = \case pure $ CRServiceReplyAccepted user (AgentConnId connId) APIGetWallet -> withUser $ \user@User {userId} -> CRWallet user <$> withFastStore' (\db -> getWalletSeed db $>>= \WalletSeed {wsId} -> Just <$> getUserAccounts db wsId userId) - APICreateWallet mnemonic_ -> withUser $ \_ -> do + APICreateWallet mnemonic_ -> withUser $ \user -> do + seed_ <- withFastStore' getWalletSeed + when (isJust seed_) $ throwWalletError WEMasterExists -- a generated seed has taken no accounts, an imported one does not say how many it has taken (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 + pure $ CRWallet user (Just []) + APIBindWalletAccount accountIdx_ -> withUser $ \user@User {userId, viewPwdHash} -> do when (isJust viewPwdHash) $ throwWalletError WEHiddenProfile - liftWallet =<< withFastStore' (\db -> bindAccount db userId accountIdx_) - processChatCommand cxt nm APIGetWallet + CRWallet user . Just <$> (liftWallet =<< withFastStore' (\db -> bindAccount db userId accountIdx_)) APIGetWalletAddress accountIdx_ -> withUser $ \user -> do (seed, n) <- liftWallet =<< withFastStore' (`resolveAccount` accountIdx_) - CRWalletAddress user . accountAddress n <$> accountKey seed n + CRWalletAddress user <$> (accountAddress n =<< accountKey seed n) APIExportWalletMnemonic -> withUser $ \user -> CRWalletMnemonic user <$> (liftWallet . seedMnemonic . wsEntropy =<< walletSeed) APIExportWalletAccount n -> withUser $ \user@User {userId} -> do @@ -1525,7 +1526,8 @@ processChatCommand cxt nm = \case 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) + a <- accountAddress n k + pure $ CRWalletAccountSecret user a (accountSecret k) APIDeleteWallet -> withUser $ \_ -> do deleted <- withFastStore' deleteWalletSeed unless deleted $ throwWalletError WENoMaster @@ -5961,11 +5963,14 @@ liftWallet :: Either WalletError a -> CM a liftWallet = either throwWalletError pure accountKey :: WalletSeed -> AccountIndex -> CM AccountKey -accountKey seed n = liftWallet $ seedMaster (wsEntropy seed) >>= (`deriveAccountKey` n) +accountKey seed n = do + master <- liftWallet =<< liftIO (seedMaster $ wsEntropy seed) + liftWallet =<< liftIO (deriveAccountKey master n) -accountAddress :: AccountIndex -> AccountKey -> WalletAddress -accountAddress n k = - WalletAddress {accountIndex = n, keyPath = renderAccountPath n, address = decodeLatin1 . strEncode $ addressFromPrivateKey k} +accountAddress :: AccountIndex -> AccountKey -> CM WalletAddress +accountAddress n k = do + a <- liftIO $ addressFromPrivateKey k + pure WalletAddress {accountIndex = n, keyPath = renderAccountPath n, address = decodeLatin1 $ strEncode a} chatCommandP :: Parser ChatCommand chatCommandP = diff --git a/src/Simplex/Chat/Remote.hs b/src/Simplex/Chat/Remote.hs index 4de4f4f6b7..6dd34a8d2f 100644 --- a/src/Simplex/Chat/Remote.hs +++ b/src/Simplex/Chat/Remote.hs @@ -552,7 +552,6 @@ liftRC = liftError (ChatErrorRemoteCtrl . RCEProtocolError) handleSend :: (ByteString -> Int -> CM' (Either ChatError ChatResponse)) -> Text -> Int -> CM' RemoteResponse handleSend execCC command retryNum = do - logDebug $ "Send: " <> tshow 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/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index d6589b464c..493a4e17b4 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -25,6 +25,7 @@ import Control.Monad.IO.Class (liftIO) import qualified Data.ByteArray as BA import Data.ByteString (ByteString) import Data.Int (Int64) +import Data.Maybe (isJust) import Simplex.Chat.Wallet (AccountIndex, WalletError (..), checkAccountIndex) import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) @@ -58,14 +59,15 @@ getWalletSeed db = -- | False if the device already has a seed. The counter is 'Nothing' for an imported phrase. 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, next_account_index) VALUES (?, ?)" - (DB.Binary (BA.convert entropy :: ByteString), accountIndexCol <$> nextAccount) + fmap isJust . maybeFirstRow (fromOnly @Int64) $ + DB.query + db + [sql| + INSERT INTO wallet_seeds (entropy, next_account_index) VALUES (?, ?) + ON CONFLICT (single_seed) DO NOTHING + RETURNING wallet_seed_id + |] + (DB.Binary (BA.convert entropy :: ByteString), accountIndexCol <$> nextAccount) -- | False if the device had no seed to delete. The account rows go with it. deleteWalletSeed :: DB.Connection -> IO Bool @@ -119,8 +121,8 @@ heldByOther userId = \case 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, the next free one when no index is given. One transaction, so none is taken twice. -bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> IO (Either WalletError ()) +-- | Bind an account to a profile, the next free one when no index is given, and return the profile's accounts. One transaction, so none is taken twice. +bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> IO (Either WalletError [AccountIndex]) bindAccount db userId accountIdx_ = runExceptT $ do (WalletSeed {wsId = sId}, n) <- ExceptT $ resolveAccount db accountIdx_ taken <- liftIO $ accountUser db sId n >>= \case @@ -129,7 +131,7 @@ bindAccount db userId accountIdx_ = runExceptT $ do Just Nothing -> setAccountUser db sId userId n >> accountHeldBy db sId userId n Nothing -> True <$ insertAccount db sId userId n unless taken $ throwError WEAccountBound - liftIO $ raiseNextAccount db sId n + liftIO $ raiseNextAccount db sId n >> getUserAccounts db sId userId setAccountUser :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO () setAccountUser db sId userId n = diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index 2e7fef8a33..83bcefd340 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -19,6 +19,7 @@ module Simplex.Chat.Wallet where import Control.Concurrent.STM +import Control.Monad.Except import Crypto.Random (ChaChaDRG) import qualified Data.Aeson.TH as JQ import qualified Data.ByteArray as BA @@ -79,10 +80,10 @@ seedMnemonic entropy = bipError . fmap (decodeLatin1 . B39.mnemonicPhrase) . B39.entropyToMnemonic $ entropyBytes entropy -- | Deriving this runs PBKDF2, so it is done once per command. -seedMaster :: BA.ScrubbedBytes -> Either WalletError B32.ExtendedKey -seedMaster entropy = do - m <- bipError . B39.entropyToMnemonic $ entropyBytes entropy - bipError . B32.masterKey $ B39.mnemonicToSeed m "" +seedMaster :: BA.ScrubbedBytes -> IO (Either WalletError B32.ExtendedKey) +seedMaster entropy = runExceptT $ do + m <- liftEither . bipError . B39.entropyToMnemonic $ entropyBytes entropy + ExceptT $ bipError <$> B32.masterKey (B39.mnemonicToSeed m "") accountPath :: AccountIndex -> [Word32] accountPath n = ethereumPath n 0 @@ -90,8 +91,8 @@ accountPath n = ethereumPath n 0 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) +deriveAccountKey :: B32.ExtendedKey -> AccountIndex -> IO (Either WalletError AccountKey) +deriveAccountKey master n = fmap B32.xkKey . bipError <$> B32.derivePath master (accountPath n) -- | As wallets take it when a key is imported on its own. accountSecret :: AccountKey -> Text diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index c134f6bb80..99604dcb27 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -6,6 +6,7 @@ module WalletTests where import ChatClient import ChatTests.DBUtils import ChatTests.Utils +import Control.Monad ((<=<)) import qualified Data.ByteArray as BA import qualified Data.ByteArray.Encoding as BAE import Data.ByteString.Char8 (ByteString) @@ -17,6 +18,7 @@ import qualified Data.Text as T import Simplex.Chat.Wallet (AccountIndex, WalletError (..), accountSecret, deriveAccountKey, entropyFromMnemonic, renderAccountPath, seedMaster, seedMnemonic) import qualified Simplex.Messaging.Crypto.BIP39 as B39 import qualified Simplex.Messaging.Crypto.Secp256k1 as S +import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Eth.Address (addressFromPrivateKey) import Simplex.Messaging.Util (safeDecodeUtf8) import Test.Hspec hiding (it) @@ -33,14 +35,18 @@ testPhrase24 = B.unwords $ replicate 23 "abandon" <> ["art"] seedEntropy :: ByteString -> BA.ScrubbedBytes seedEntropy phrase = BA.convert . B39.mnemonicToEntropy . either error id $ B39.parseMnemonic phrase -accountKey :: BA.ScrubbedBytes -> AccountIndex -> S.Secp256k1PrivateKey -accountKey entropy n = either (error . show) id $ seedMaster entropy >>= \m -> deriveAccountKey m n +accountKey :: BA.ScrubbedBytes -> AccountIndex -> IO S.Secp256k1PrivateKey +accountKey entropy n = do + m <- either (error . show) id <$> seedMaster entropy + either (error . show) id <$> deriveAccountKey m n + +addressOf :: S.Secp256k1PrivateKey -> IO String +addressOf k = B.unpack . strEncode <$> addressFromPrivateKey k -- | The address a wallet reaches when the secret is imported as a private key. -addressFromSecret :: String -> String +addressFromSecret :: String -> IO String addressFromSecret secret = - show . addressFromPrivateKey . either error id . S.mkPrivateKey . either error id $ - BAE.convertFromBase BAE.Base16 (B.drop 2 $ B.pack secret) + addressOf . either error id =<< S.mkPrivateKey (either error id $ BAE.convertFromBase BAE.Base16 (B.drop 2 $ B.pack secret)) -- | An @export account@ row: the index, the path, the address, the secret. exportRow :: HasCallStack => String -> (String, String, String, String) @@ -51,21 +57,21 @@ exportRow row = case words row of walletDerivationTests :: Spec walletDerivationTests = do Hspec.it "accounts are the accounts another wallet derives for the same phrase" $ do - let addrOf = show . addressFromPrivateKey . accountKey (seedEntropy testPhrase12) + let addrOf n = addressOf =<< accountKey (seedEntropy testPhrase12) n -- Ledger Live accounts 1 and 2 for this phrase, the published values for it - addrOf 0 `shouldBe` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" - addrOf 1 `shouldBe` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265" + addrOf 0 `shouldReturn` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" + addrOf 1 `shouldReturn` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265" Hspec.it "the exported secret is the one another wallet shows for that account" $ do - let k = accountKey (seedEntropy testPhrase12) 0 + k <- accountKey (seedEntropy 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 entropy = seedEntropy testPhrase12 - addrs = map (show . addressFromPrivateKey . accountKey entropy) [0 .. 9] + addrs <- mapM (addressOf <=< accountKey entropy) [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 + k <- either error id <$> S.mkPrivateKey (BA.convert $ B.pack ('\0' : replicate 31 '\1')) + let secret = T.unpack $ accountSecret k take 4 secret `shouldBe` "0x00" length secret `shouldBe` 66 Hspec.it "renders the path an account sits at" $ do @@ -219,13 +225,13 @@ testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do path `shouldBe` "m/44'/60'/0'/0/0" -- m/44'/60'/0'/0/0 of the 24 word vector, pinned so a change of path fails here address `shouldBe` "0xF278cF59F82eDcf871d630F28EcC8056f25C1cdb" - addressFromSecret secret `shouldBe` address + addressFromSecret secret `shouldReturn` address -- the index reaches the key, not only the path printed beside it alice ##> "/_wallet export account 1" (idx', path', address', _) <- exportRow <$> getTermLine alice idx' `shouldBe` "1" path' `shouldBe` "m/44'/60'/1'/0/0" - address' `shouldBe` show (addressFromPrivateKey $ accountKey (seedEntropy testPhrase24) 1) + (addressOf =<< accountKey (seedEntropy testPhrase24) 1) `shouldReturn` address' -- and an address is read from the account the command names, not the counter alice ##> "/_wallet address account=1" addressRow <- words <$> getTermLine alice From 68c84a9e75a14854bf09cd9a191af306f063e6c6 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 24 Sep 2026 11:37:21 +0200 Subject: [PATCH 52/67] self-review iteration 2 --- bots/api/TYPES.md | 3 + bots/src/API/Docs/Commands.hs | 4 +- cabal.project | 2 +- docs/rfcs/2026-09-10-wallet-keys.md | 46 ++-- .../types/typescript/src/types.ts | 6 + .../src/simplex_chat/types/_types.py | 6 +- scripts/nix/sha256map.nix | 2 +- simplex-chat.cabal | 2 +- src/Simplex/Chat/Controller.hs | 8 +- src/Simplex/Chat/Library/Commands.hs | 69 +++--- .../Migrations/M20260924_wallet_seeds.hs | 1 - .../Migrations/M20260924_wallet_seeds.hs | 2 +- .../SQLite/Migrations/chat_query_plans.txt | 47 ++++ .../Store/SQLite/Migrations/chat_schema.sql | 2 +- src/Simplex/Chat/Store/Wallets.hs | 86 +++---- src/Simplex/Chat/View.hs | 3 +- src/Simplex/Chat/Wallet.hs | 74 +++--- tests/PostgresSchemaDump.hs | 10 +- tests/SchemaDump.hs | 10 +- tests/WalletTests.hs | 232 ++++++++---------- 20 files changed, 296 insertions(+), 319 deletions(-) diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 82db41b533..5aef0a6102 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -4659,6 +4659,9 @@ HiddenProfile: AccountBound: - type: "accountBound" +AccountNotHeld: +- type: "accountNotHeld" + CounterUnknown: - type: "counterUnknown" diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 3435bdb6e5..64ee8bf09a 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -369,19 +369,19 @@ undocumentedCommands = "APIAcceptMemberContact", "APICreateWallet", "APIDeleteChatTag", - "APIDeleteWallet", "APIDeleteMemberSupportChat", "APIDeleteReceivedReports", "APIDeleteStorage", "APIDeleteToken", + "APIDeleteWallet", "APIDownloadStandaloneFile", "APIEnableContact", "APIEnableGroupMember", "APIEndCall", "APIExportArchive", - "APIForwardChatItems", "APIExportWalletAccount", "APIExportWalletMnemonic", + "APIForwardChatItems", "APIGetAppSettings", "APIGetBadgeLedger", "APIGetBadgeState", diff --git a/cabal.project b/cabal.project index b11a45c380..abb3a7d37e 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: b589c2497a0a956ddc5454ddd9f7f2dbe305740d + tag: 714803f7a00f51379621b131ffa561d7b61c26d2 source-repository-package type: git diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index dab6ed31bb..0ab352b65f 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -65,28 +65,31 @@ A hidden profile is bound no account, so it cannot own a name. Two things would An internal API, called by the names commands and by whatever else takes accounts later, rather than typed by users. Nothing here runs at startup or as a side effect of reading. ``` -/_wallet whether the device has a master, and the - accounts the active profile is bound to -/_wallet create new generate the master, 24 words -/_wallet create mnemonic= take the master entropy from a phrase, 24 words -/_wallet bind account= bind an account to the active profile; - without account=, the next free one -/_wallet address account= one address, with the account index it came - from; without account=, the next free one -/_wallet export master the master phrase -/_wallet export account one account key's secret, 0x and 64 hex -/_wallet delete delete the master entropy and its accounts +/_wallet whether the device has a master, and the + accounts the profile is bound to +/_wallet create new generate the master, 24 words +/_wallet create mnemonic= take the master entropy from a phrase, 24 words +/_wallet bind account= bind an account to the profile and return its + address; without account=, the next free one +/_wallet address account= one address, with the account index it came + from; without account=, the next free one +/_wallet export master the master phrase +/_wallet export account the secret of an account the profile holds, + 0x and 64 hex +/_wallet delete delete the master entropy and its accounts ``` +A command that acts on a profile's accounts names the profile and is refused when that profile is not the active one, so switching profiles during a flow cannot bind an account to the wrong profile. + `create` always names its source, so no key material is made by typing a prefix. An imported phrase must be 24 BIP-39 English words with a valid checksum, normalised for case and spacing. There is no BIP-39 passphrase, because it would be a second secret to back up and losing it would look exactly like losing the phrase, so a phrase used with one elsewhere lands on a different tree here and finds nothing. -`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. +`bind` without an argument takes the next free account from a counter on the master, which is a high-water mark and not a count of what is held, and returns the bound account's address, so the caller knows which account it received. Named with `account=` it takes that one, which is how an account found by a scan is attached to the profile that should have it, and it is refused for an account another profile holds. After an import the counter is unknown rather than zero, because the phrase does not say how many accounts it has been used for, so taking a new one is refused until a scan sets it, while binding a known account is still allowed. 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 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. +`export account` is refused unless the profile holds the account, so every exported key belongs to an account the counter has already passed, and no later `bind` returns it as a new account. An export is a copy and not a handover: the device still derives what it exported and can still sign with it, so giving an account key away leaves two parties able to act as its owner until whatever it holds is transferred on chain. Signing is not in this change, and when it is added it is a command here that signs and returns a signature, not `export account` followed by signing elsewhere, which would make the narrow export the ordinary path. `delete` leaves accounts registered to their addresses, reachable only by the phrase. ```haskell data WalletAddress = WalletAddress {accountIndex :: Word32, keyPath :: Text, address :: Text} @@ -96,10 +99,11 @@ 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 or export account, on an account another profile holds + | WEAccountBound -- bind, on an account another profile holds + | WEAccountNotHeld -- export account, on an account the profile does not hold | WECounterUnknown -- no counter to read yet, after an import | WEIndexTooLarge -- at or above 2^31 - | WEDerivation {derivationError :: String} -- BIP-32 or BIP-39 said no + | WEDerivation {derivationError :: String} -- BIP-32 or BIP-39 derivation failed ``` ## Recovery @@ -138,9 +142,9 @@ CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id); Only entropy that nothing can derive is stored: the master, always 32 bytes, since it is made and imported as 24 words. An account key is never stored, because the master entropy and an account index derive it whenever one is needed. So `wallet_accounts` holds what derivation cannot produce, which account indexes the device knows about and which profile each belongs to. A row with no `user_id` is an account no profile holds, which is what a deleted chat profile leaves behind and what a scan writes. -`users` is not touched: the mapping lives on the account row, and the index on `user_id` is not unique, because a profile owns as many accounts as it owns names. One seed per device is `single_seed` and the unique index on it, which a later change lifts with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop one of those without rebuilding the table. Deleting the master takes its account rows, because an account index with no entropy behind it derives nothing. The migration has no reverse step, because reversing it would drop the only copy of the master entropy. What runs a reverse step is an older app installed over a newer database, which on mobile happens without asking and leaves one backup file that the next upgrade overwrites; with no reverse step that older app reports instead that the database is newer than it is, and changes nothing. +`users` is not touched: the mapping lives on the account row, and the index on `user_id` is not unique, because a profile owns as many accounts as it owns names. One seed per device is `single_seed` and the unique index on it, which a later change lifts with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop one of those without rebuilding the table. Deleting the master takes its account rows, because an account index with no entropy behind it derives nothing. The migration has no reverse step, because reversing it would drop the only copy of the master entropy. A reverse step runs when an older app opens a newer database and the user confirms "Downgrade and open chat", and the backup made then is overwritten by the next upgrade. With no reverse step the older app reports that the database is newer than it is, and changes nothing. -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. +A null `account_index` marks an account whose key was imported rather than derived, which the master phrase does not recover and the schema must not suggest it does. Importing one is not implemented here; the column is nullable now so that a row written later reads correctly. That feature also requires storage for the imported secret and an optional link to a seed, because such an account belongs to no seed and must not be deleted with one; on SQLite, making `wallet_seed_id` nullable rebuilds the table. ## Threat model @@ -167,12 +171,12 @@ A null `account_index` marks an account whose key was imported rather than deriv ## Files -- `src/Simplex/Chat/Wallet.hs`, derivation, pure. +- `src/Simplex/Chat/Wallet.hs`, derivation. - `src/Simplex/Chat/Store/Wallets.hs`, the two tables. - `src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs` and the Postgres twin. - `tests/WalletTests.hs`. - `tests/SchemaDump.hs` and `tests/PostgresSchemaDump.hs`, which selected what to test by taking every migration after the last one without a reverse step, and now take every migration from the first one that has a reverse step, applying any that has none. -- Derivation uses the `BIP32` and `BIP39` modules already in simplexmq and adds no dependency. +- Derivation uses the `BIP32`, `BIP39` and `Eth.Address` modules from simplexmq (simplex-chat/simplexmq#1843) and adds no dependency to this package. ## What is verified @@ -180,6 +184,6 @@ A null `account_index` marks an account whose key was imported rather than deriv 1. **Vectors.** The two addresses above reproduce from `abandon ... about`, as does account 0's secret, pinned to the value another wallet shows for it. A 24 word phrase imported through the command reaches a pinned address end to end, so a change of path fails here rather than shipping, and the account a command names is the account whose key comes back. 2. **Isolation.** Ten accounts' addresses are all different, and an account path hardens its account component. -3. **Refusals.** A second generate; a phrase that is not 24 valid words; `bind`, `delete` and `export master` on a device with no wallet; `bind` on a hidden profile, on an account another profile holds, and on an imported master whose counter is unknown; `export account` for an account 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 and exports its own, an account bound by index moves the counter past it so the next one does not collide and never moves it back, `bind account=` attaches a scanned one, an account a deleted profile leaves behind is taken by another profile, and `address` returns the counter twice running without moving it and derives for an account with no row. +3. **Refusals.** A second generate; a phrase that is not 24 valid words; `bind`, `delete` and `export master` on a device with no wallet; `bind` on a hidden profile, on an account another profile holds, and on an imported master whose counter is unknown; `export account` for an account the profile does not hold; every index at or above 2^31, on `address`, `bind` and `export account` alike. +4. **Binding and reads.** A profile binds several accounts, each `bind` returns the account it bound, and the profile exports its own, an account bound by index moves the counter past it so the next one does not collide and never moves it back, `bind account=` attaches a scanned one, an account a deleted profile leaves behind is taken by another profile, and `address` returns the counter twice running without moving it and derives for an account with no row. 5. **Encoding and persistence.** An account secret whose first byte is zero keeps its 64 hex digits; the wallet, its accounts, the counter and the phrase survive a restart; and deleting the wallet takes its accounts and starts the counter over. diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 5ec1b205b0..c39b736d0b 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -5313,6 +5313,7 @@ export type WalletError = | WalletError.BadMnemonic | WalletError.HiddenProfile | WalletError.AccountBound + | WalletError.AccountNotHeld | WalletError.CounterUnknown | WalletError.IndexTooLarge | WalletError.Derivation @@ -5324,6 +5325,7 @@ export namespace WalletError { | "badMnemonic" | "hiddenProfile" | "accountBound" + | "accountNotHeld" | "counterUnknown" | "indexTooLarge" | "derivation" @@ -5352,6 +5354,10 @@ export namespace WalletError { type: "accountBound" } + export interface AccountNotHeld extends Interface { + type: "accountNotHeld" + } + export interface CounterUnknown extends Interface { type: "counterUnknown" } 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 b853ae196e..1207c36110 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -3737,6 +3737,9 @@ class WalletError_hiddenProfile(TypedDict): class WalletError_accountBound(TypedDict): type: Literal["accountBound"] +class WalletError_accountNotHeld(TypedDict): + type: Literal["accountNotHeld"] + class WalletError_counterUnknown(TypedDict): type: Literal["counterUnknown"] @@ -3753,12 +3756,13 @@ WalletError = ( | WalletError_badMnemonic | WalletError_hiddenProfile | WalletError_accountBound + | WalletError_accountNotHeld | WalletError_counterUnknown | WalletError_indexTooLarge | WalletError_derivation ) -WalletError_Tag = Literal["noMaster", "masterExists", "badMnemonic", "hiddenProfile", "accountBound", "counterUnknown", "indexTooLarge", "derivation"] +WalletError_Tag = Literal["noMaster", "masterExists", "badMnemonic", "hiddenProfile", "accountBound", "accountNotHeld", "counterUnknown", "indexTooLarge", "derivation"] class XFTPErrorType_BLOCK(TypedDict): type: Literal["BLOCK"] diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 71b6c178f4..3e6b1fd6ec 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."b589c2497a0a956ddc5454ddd9f7f2dbe305740d" = "1mc880zpbrj9by3vf6xdsxskf90z83fl77jkhls54qw27rsg1a1x"; + "https://github.com/simplex-chat/simplexmq.git"."714803f7a00f51379621b131ffa561d7b61c26d2" = "1ki45ifixfgdkyg0xrgirr9w8821aszfz304l3fb4fbv0aq4dk83"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/simplex-chat.cabal b/simplex-chat.cabal index e1bfecf4a0..ebe25c6652 100644 --- a/simplex-chat.cabal +++ b/simplex-chat.cabal @@ -45,7 +45,6 @@ library Simplex.Chat.Badges.Service Simplex.Chat.Badges.Types Simplex.Chat.Names - Simplex.Chat.Wallet Simplex.Chat.Call Simplex.Chat.Controller Simplex.Chat.Delivery @@ -103,6 +102,7 @@ library Simplex.Chat.Types.Shared Simplex.Chat.Types.UITheme Simplex.Chat.Util + Simplex.Chat.Wallet Simplex.Chat.Web if !flag(client_library) exposed-modules: diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index ae873662e0..6d4aa098a2 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -68,8 +68,8 @@ import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme -import Simplex.Chat.Wallet (AccountIndex, WalletAddress, WalletError) import Simplex.Chat.Util (liftIOEither) +import Simplex.Chat.Wallet (AccountIndex, WalletAddress, WalletError) import Simplex.FileTransfer.Description (FileDescriptionURI) import Simplex.Messaging.Server.Information (ServerPublicInfo) import Simplex.Messaging.Agent (AgentClient, DatabaseDiff, SubscriptionsInfo) @@ -437,12 +437,12 @@ 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} - | APIGetWallet + | APIGetWallet {userId :: UserId} | APICreateWallet {mnemonic :: Maybe Text} - | APIBindWalletAccount {accountIndex_ :: Maybe AccountIndex} + | APIBindWalletAccount {userId :: UserId, accountIndex_ :: Maybe AccountIndex} | APIGetWalletAddress {accountIndex_ :: Maybe AccountIndex} | APIExportWalletMnemonic - | APIExportWalletAccount {accountIndex :: AccountIndex} + | APIExportWalletAccount {userId :: UserId, accountIndex :: AccountIndex} | APIDeleteWallet | APISendCallInvitation ContactId CallType | SendCallInvitation ContactName CallType diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index c112c7ddb0..8214b686d2 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -65,9 +65,8 @@ import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeIss import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Badges.Service (BadgeBalance (..), BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), BadgeStatement (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..), currentBadgeServiceVersion) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) -import Simplex.Chat.Store.Wallets (WalletSeed (..), accountHeldByOther, bindAccount, createWalletSeed, deleteWalletSeed, getUserAccounts, getWalletSeed, resolveAccount) -import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress (..), WalletError (..), accountSecret, deriveAccountKey, entropyFromMnemonic, newSeedEntropy, renderAccountPath, seedMaster, seedMnemonic) -import Simplex.Messaging.Eth.Address (addressFromPrivateKey) +import Simplex.Chat.Store.Wallets (WalletSeed (..), accountHeldBy, bindAccount, createWalletSeed, deleteWalletSeed, getUserAccounts, getWalletSeed, resolveAccount) +import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress, WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, newSeedEntropy, seedMnemonic) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -1500,33 +1499,33 @@ 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) - APIGetWallet -> withUser $ \user@User {userId} -> - CRWallet user <$> withFastStore' (\db -> getWalletSeed db $>>= \WalletSeed {wsId} -> Just <$> getUserAccounts db wsId userId) + APIGetWallet userId -> withUserId userId $ \user -> + CRWallet user <$> withFastStore' (\db -> getWalletSeed db >>= mapM (\WalletSeed {wsId} -> getUserAccounts db wsId userId)) APICreateWallet mnemonic_ -> withUser $ \user -> do seed_ <- withFastStore' getWalletSeed when (isJust seed_) $ throwWalletError WEMasterExists - -- a generated seed has taken no accounts, an imported one does not say how many it has taken + -- the counter starts at 0 for a generated seed and is unknown for an imported one (entropy, nextAccount) <- case mnemonic_ of Nothing -> (,Just 0) <$> (asks random >>= atomically . newSeedEntropy) Just phrase -> (,Nothing) <$> liftWallet (entropyFromMnemonic $ encodeUtf8 phrase) created <- withFastStore' $ \db -> createWalletSeed db entropy nextAccount unless created $ throwWalletError WEMasterExists pure $ CRWallet user (Just []) - APIBindWalletAccount accountIdx_ -> withUser $ \user@User {userId, viewPwdHash} -> do + APIBindWalletAccount userId accountIdx_ -> withUserId userId $ \user@User {viewPwdHash} -> do when (isJust viewPwdHash) $ throwWalletError WEHiddenProfile - CRWallet user . Just <$> (liftWallet =<< withFastStore' (\db -> bindAccount db userId accountIdx_)) + (seed, n) <- withWalletStore $ \db -> bindAccount db userId accountIdx_ + CRWalletAddress user . snd <$> seedAccount seed n APIGetWalletAddress accountIdx_ -> withUser $ \user -> do - (seed, n) <- liftWallet =<< withFastStore' (`resolveAccount` accountIdx_) - CRWalletAddress user <$> (accountAddress n =<< accountKey seed n) - APIExportWalletMnemonic -> withUser $ \user -> - CRWalletMnemonic user <$> (liftWallet . seedMnemonic . wsEntropy =<< walletSeed) - APIExportWalletAccount n -> withUser $ \user@User {userId} -> do - (seed, _) <- liftWallet =<< withFastStore' (`resolveAccount` Just n) - -- 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 - a <- accountAddress n k + (seed, n) <- withWalletStore (`resolveAccount` accountIdx_) + CRWalletAddress user . snd <$> seedAccount seed n + APIExportWalletMnemonic -> withUser $ \user -> do + WalletSeed {wsEntropy} <- withFastStore' getWalletSeed >>= maybe (throwWalletError WENoMaster) pure + CRWalletMnemonic user <$> liftWallet (seedMnemonic wsEntropy) + APIExportWalletAccount userId n -> withUserId userId $ \user -> do + (seed@WalletSeed {wsId}, _) <- withWalletStore (`resolveAccount` Just n) + held <- withFastStore' $ \db -> accountHeldBy db wsId userId n + unless held $ throwWalletError WEAccountNotHeld + (k, a) <- seedAccount seed n pure $ CRWalletAccountSecret user a (accountSecret k) APIDeleteWallet -> withUser $ \_ -> do deleted <- withFastStore' deleteWalletSeed @@ -6026,24 +6025,17 @@ withExpirationDate globalTTL chatItemTTL action = do let ttl = fromMaybe globalTTL chatItemTTL when (ttl > 0) $ action $ addUTCTime (-1 * fromIntegral ttl) currentTs -walletSeed :: CM WalletSeed -walletSeed = withFastStore' getWalletSeed >>= maybe (throwWalletError WENoMaster) pure - throwWalletError :: WalletError -> CM a throwWalletError = throwChatError . CEWallet liftWallet :: Either WalletError a -> CM a liftWallet = either throwWalletError pure -accountKey :: WalletSeed -> AccountIndex -> CM AccountKey -accountKey seed n = do - master <- liftWallet =<< liftIO (seedMaster $ wsEntropy seed) - liftWallet =<< liftIO (deriveAccountKey master n) +withWalletStore :: (DB.Connection -> IO (Either WalletError a)) -> CM a +withWalletStore action = liftWallet =<< withFastStore' action -accountAddress :: AccountIndex -> AccountKey -> CM WalletAddress -accountAddress n k = do - a <- liftIO $ addressFromPrivateKey k - pure WalletAddress {accountIndex = n, keyPath = renderAccountPath n, address = decodeLatin1 $ strEncode a} +seedAccount :: WalletSeed -> AccountIndex -> CM (AccountKey, WalletAddress) +seedAccount WalletSeed {wsEntropy} n = liftWallet =<< liftIO (deriveAccount wsEntropy n) chatCommandP :: Parser ChatCommand chatCommandP = @@ -6169,14 +6161,12 @@ chatCommandP = "/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP), "/_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 bind " *> (APIBindWalletAccount <$> A.decimal <*> optional (" account=" *> accountIndexP)), + "/_wallet address" *> (APIGetWalletAddress <$> optional (" account=" *> accountIndexP)), "/_wallet export master" $> APIExportWalletMnemonic, - "/_wallet export account " *> (APIExportWalletAccount <$> accountIndexP), + "/_wallet export account " *> (APIExportWalletAccount <$> A.decimal <* A.space <*> accountIndexP), "/_wallet delete" $> APIDeleteWallet, - "/_wallet" $> APIGetWallet, + "/_wallet " *> (APIGetWallet <$> A.decimal), "/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP), "/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType), "/_call reject @" *> (APIRejectCall <$> A.decimal), @@ -6720,12 +6710,9 @@ chatCommandP = quotedP = safeDecodeUtf8 <$> (A.char '"' *> A.takeTill (== '"') <* A.char '"') text1P = safeDecodeUtf8 <$> A.takeTill (== ' ') char_ = optional . A.char - -- a long digit run is not free to convert; the hardening bound is checked when the command runs accountIndexP = do - ds <- A.takeWhile1 isDigit - case if B.length ds <= 10 then B.readInteger ds else Nothing of - Just (i, _) | i <= toInteger (maxBound :: AccountIndex) -> pure (fromInteger i) - _ -> fail "account index too large" + i <- A.decimal + if i <= toInteger (maxBound :: AccountIndex) then pure (fromInteger i) else fail "account index too large" displayNameP :: Parser Text displayNameP = safeDecodeUtf8 <$> displayNameP_ diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs index 9640249a6e..68fff6d313 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs @@ -9,7 +9,6 @@ import Text.RawString.QQ (r) m20260924_wallet_seeds :: Text m20260924_wallet_seeds = [r| --- the columns are commented in the SQLite migration CREATE TABLE wallet_seeds ( wallet_seed_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, entropy BYTEA NOT NULL CHECK (length(entropy) = 32), diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs index 7d31f1cf1c..4d998fe751 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs @@ -10,7 +10,7 @@ m20260924_wallet_seeds = [sql| CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, - entropy BLOB NOT NULL CHECK (length(entropy) = 32), -- BIP-39 entropy, 24 words + entropy BLOB NOT NULL CHECK (length(entropy) = 32), next_account_index INTEGER CHECK (next_account_index BETWEEN 0 AND 2147483648), single_seed INTEGER NOT NULL DEFAULT 1 ) STRICT; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index 152d6c0c8d..c04877014a 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -2048,6 +2048,14 @@ Query: Plan: +Query: + INSERT INTO wallet_seeds (entropy, next_account_index) VALUES (?, ?) + ON CONFLICT (single_seed) DO NOTHING + RETURNING wallet_seed_id + +Plan: +SEARCH wallet_accounts USING COVERING INDEX idx_wallet_accounts_wallet_seed_id_account_index (wallet_seed_id=?) + Query: SELECT (SELECT prev.balance_start_ts FROM badge_ledger prev @@ -3502,6 +3510,14 @@ SCAN CONSTANT ROW SCALAR SUBQUERY 1 SEARCH groups USING INDEX idx_groups_relay_request_group_link (user_id=? AND relay_request_group_link=?) +Query: + SELECT account_index FROM wallet_accounts + WHERE wallet_seed_id = ? AND user_id = ? AND account_index IS NOT NULL + ORDER BY account_index + +Plan: +SEARCH wallet_accounts USING INDEX idx_wallet_accounts_wallet_seed_id_account_index (wallet_seed_id=? AND account_index>?) + Query: SELECT agent_conn_id FROM connections @@ -5707,6 +5723,20 @@ Query: Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE wallet_accounts SET user_id = ? + WHERE wallet_seed_id = ? AND account_index = ? AND user_id IS NULL + +Plan: +SEARCH wallet_accounts USING INDEX idx_wallet_accounts_wallet_seed_id_account_index (wallet_seed_id=? AND account_index=?) + +Query: + UPDATE wallet_seeds SET next_account_index = ? + WHERE wallet_seed_id = ? AND next_account_index IS NOT NULL AND next_account_index <= ? + +Plan: +SEARCH wallet_seeds USING INTEGER PRIMARY KEY (rowid=?) + Query: UPDATE xftp_file_descriptions SET file_descr_text = ?, file_descr_part_no = ?, file_descr_complete = ?, updated_at = ? @@ -7109,6 +7139,7 @@ SEARCH connections USING COVERING INDEX idx_connections_user_contact_link_id (us Query: DELETE FROM users WHERE user_id = ? Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) +SEARCH wallet_accounts USING COVERING INDEX idx_wallet_accounts_user_id (user_id=?) SEARCH badge_code_redemptions USING COVERING INDEX idx_badge_code_redemptions_user (user_id=?) SEARCH badge_purchases USING COVERING INDEX idx_badge_purchases_user (user_id=?) SEARCH chat_relays USING COVERING INDEX idx_chat_relays_user_id (user_id=?) @@ -7136,6 +7167,11 @@ SEARCH contacts USING COVERING INDEX sqlite_autoindex_contacts_2 (user_id=?) SEARCH display_names USING COVERING INDEX sqlite_autoindex_display_names_2 (user_id=?) SEARCH contact_profiles USING COVERING INDEX idx_contact_profiles_user_id (user_id=?) +Query: DELETE FROM wallet_seeds RETURNING wallet_seed_id +Plan: +SCAN wallet_seeds +SEARCH wallet_accounts USING COVERING INDEX idx_wallet_accounts_wallet_seed_id_account_index (wallet_seed_id=?) + Query: DROP TABLE IF EXISTS temp_delete_members Plan: @@ -7259,6 +7295,9 @@ Plan: Query: INSERT INTO users (agent_user_id, local_display_name, active_user, is_user_chat_relay, active_order, contact_id, show_ntfs, send_rcpts_contacts, send_rcpts_small_groups, auto_accept_member_contacts, auto_accept_group_invitations, client_service, created_at, updated_at) VALUES (?,?,?,?,?,0,?,?,?,?,?,?,?,?) Plan: +Query: INSERT INTO wallet_accounts (wallet_seed_id, account_index, user_id) VALUES (?, ?, ?) +Plan: + Query: INSERT INTO xftp_file_descriptions (user_id, file_descr_text, file_descr_part_no, file_descr_complete, created_at, updated_at) VALUES (?,?,?,?,?,?) Plan: @@ -7768,6 +7807,10 @@ Query: SELECT user_id FROM users WHERE local_display_name = ? Plan: SEARCH users USING COVERING INDEX sqlite_autoindex_users_2 (local_display_name=?) +Query: SELECT user_id FROM wallet_accounts WHERE wallet_seed_id = ? AND account_index = ? +Plan: +SEARCH wallet_accounts USING INDEX idx_wallet_accounts_wallet_seed_id_account_index (wallet_seed_id=? AND account_index=?) + Query: SELECT via_contact_uri FROM connections WHERE connection_id = ? Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) @@ -7776,6 +7819,10 @@ Query: SELECT via_contact_uri, via_contact_uri_hash FROM connections WHERE conne Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) +Query: SELECT wallet_seed_id, entropy, next_account_index FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1 +Plan: +SCAN wallet_seeds + Query: SELECT xgrplinkmem_received FROM group_members WHERE group_member_id = ? Plan: SEARCH group_members USING INTEGER PRIMARY KEY (rowid=?) diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index febb17586b..66c08c5373 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -983,7 +983,7 @@ CREATE TABLE badge_code_redemptions( ) STRICT; CREATE TABLE wallet_seeds( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, - entropy BLOB NOT NULL CHECK(length(entropy) = 32), -- BIP-39 entropy, 24 words + entropy BLOB NOT NULL CHECK(length(entropy) = 32), next_account_index INTEGER CHECK(next_account_index BETWEEN 0 AND 2147483648), single_seed INTEGER NOT NULL DEFAULT 1 ) STRICT; diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index 493a4e17b4..677d2057bd 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -5,7 +5,6 @@ {-# LANGUAGE QuasiQuotes #-} {-# LANGUAGE TypeApplications #-} --- | The device seed, and which chat profile each account belongs to. module Simplex.Chat.Store.Wallets ( SeedId, WalletSeed (..), @@ -14,12 +13,13 @@ module Simplex.Chat.Store.Wallets deleteWalletSeed, resolveAccount, getUserAccounts, - accountHeldByOther, + accountHeldBy, bindAccount, ) where -import Control.Monad (join, unless) +import Control.Applicative ((<|>)) +import Control.Monad (unless) import Control.Monad.Except import Control.Monad.IO.Class (liftIO) import qualified Data.ByteArray as BA @@ -41,25 +41,23 @@ import Database.SQLite.Simple.QQ (sql) type SeedId = Int64 --- | The device seed row. The entropy is 'BA.ScrubbedBytes', so a derived 'Show' does not print it. data WalletSeed = WalletSeed { wsId :: SeedId, - wsEntropy :: BA.ScrubbedBytes + wsEntropy :: BA.ScrubbedBytes, + wsNextAccount :: Maybe AccountIndex } - deriving (Show) -toSeed :: (Int64, ByteString) -> WalletSeed -toSeed (sId, entropy) = WalletSeed {wsId = sId, wsEntropy = BA.convert entropy} +toSeed :: (SeedId, ByteString, Maybe AccountIndex) -> WalletSeed +toSeed (wsId, entropy, wsNextAccount) = WalletSeed {wsId, wsEntropy = BA.convert entropy, wsNextAccount} 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" + DB.query_ db "SELECT wallet_seed_id, entropy, next_account_index FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1" --- | False if the device already has a seed. The counter is 'Nothing' for an imported phrase. createWalletSeed :: DB.Connection -> BA.ScrubbedBytes -> Maybe AccountIndex -> IO Bool createWalletSeed db entropy nextAccount = - fmap isJust . maybeFirstRow (fromOnly @Int64) $ + fmap isJust . maybeFirstRow (fromOnly @SeedId) $ DB.query db [sql| @@ -67,36 +65,23 @@ createWalletSeed db entropy nextAccount = ON CONFLICT (single_seed) DO NOTHING RETURNING wallet_seed_id |] - (DB.Binary (BA.convert entropy :: ByteString), accountIndexCol <$> nextAccount) + (DB.Binary (BA.convert entropy :: ByteString), nextAccount) --- | False if the device had no seed to delete. The account rows go with it. deleteWalletSeed :: DB.Connection -> IO Bool deleteWalletSeed db = - getWalletSeed db >>= \case - Nothing -> pure False - Just WalletSeed {wsId} -> - True <$ DB.execute db "DELETE FROM wallet_seeds WHERE wallet_seed_id = ?" (Only wsId) + fmap isJust . maybeFirstRow (fromOnly @SeedId) $ + DB.query_ db "DELETE FROM wallet_seeds RETURNING wallet_seed_id" --- | The seed, and the account named or the next free one from the counter. Refuses an index BIP-32 cannot harden. resolveAccount :: DB.Connection -> Maybe AccountIndex -> IO (Either WalletError (WalletSeed, AccountIndex)) resolveAccount db accountIdx_ = runExceptT $ do - seed <- ExceptT $ maybe (Left WENoMaster) Right <$> getWalletSeed db - n <- maybe (nextFreeAccount $ wsId seed) pure accountIdx_ + seed@WalletSeed {wsNextAccount} <- ExceptT $ maybe (Left WENoMaster) Right <$> getWalletSeed db + n <- liftEither $ maybe (Left WECounterUnknown) Right (accountIdx_ <|> wsNextAccount) liftEither $ checkAccountIndex n pure (seed, n) - where - nextFreeAccount sId = ExceptT $ maybe (Left WECounterUnknown) Right <$> getNextAccountIndex db sId --- | The index the next account takes. Nothing after an import. -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. getUserAccounts :: DB.Connection -> SeedId -> UserId -> IO [AccountIndex] getUserAccounts db sId userId = - map (fromIntegral @Int64 . fromOnly) + map fromOnly <$> DB.query db [sql| @@ -107,31 +92,22 @@ getUserAccounts db sId userId = (sId, userId) -- | Which profile holds an account: 'Nothing' when it is unknown, @Just Nothing@ when no profile holds it. -accountUser :: DB.Connection -> SeedId -> AccountIndex -> IO (Maybe (Maybe Int64)) +accountUser :: DB.Connection -> SeedId -> AccountIndex -> IO (Maybe (Maybe UserId)) 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) + maybeFirstRow fromOnly $ + DB.query db "SELECT user_id FROM wallet_accounts WHERE wallet_seed_id = ? AND account_index = ?" (sId, n) --- | True when another profile holds the account. A guard, not a boundary: @export master@ reaches every account. -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, the next free one when no index is given, and return the profile's accounts. One transaction, so none is taken twice. -bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> IO (Either WalletError [AccountIndex]) +bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> IO (Either WalletError (WalletSeed, AccountIndex)) bindAccount db userId accountIdx_ = runExceptT $ do - (WalletSeed {wsId = sId}, n) <- ExceptT $ resolveAccount db accountIdx_ - taken <- liftIO $ accountUser db sId n >>= \case + r@(WalletSeed {wsId}, n) <- ExceptT $ resolveAccount db accountIdx_ + taken <- liftIO $ accountUser db wsId n >>= \case Just (Just heldBy) -> pure $ heldBy == userId - -- the update takes the account only while no profile holds it, the read after says whether this one got it - Just Nothing -> setAccountUser db sId userId n >> accountHeldBy db sId userId n - Nothing -> True <$ insertAccount db sId userId n + -- the update sets user_id only while it is NULL, so the read after it shows which profile holds the account + Just Nothing -> setAccountUser db wsId userId n >> accountHeldBy db wsId userId n + Nothing -> True <$ insertAccount db wsId userId n unless taken $ throwError WEAccountBound - liftIO $ raiseNextAccount db sId n >> getUserAccounts db sId userId + liftIO $ raiseNextAccount db wsId n + pure r setAccountUser :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO () setAccountUser db sId userId n = @@ -141,12 +117,11 @@ setAccountUser db sId userId n = UPDATE wallet_accounts SET user_id = ? WHERE wallet_seed_id = ? AND account_index = ? AND user_id IS NULL |] - (userId, sId, accountIndexCol n) + (userId, sId, n) accountHeldBy :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bool accountHeldBy db sId userId n = (== Just (Just userId)) <$> accountUser db sId n --- | Keep the counter a high-water mark. Never lowers it, never gives one to an imported phrase that has none. raiseNextAccount :: DB.Connection -> SeedId -> AccountIndex -> IO () raiseNextAccount db sId n = DB.execute @@ -155,11 +130,8 @@ raiseNextAccount db sId n = 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) + (n + 1, sId, 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) - -accountIndexCol :: AccountIndex -> Int64 -accountIndexCol = fromIntegral + DB.execute db "INSERT INTO wallet_accounts (wallet_seed_id, account_index, user_id) VALUES (?, ?, ?)" (sId, n, userId) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index ae6b0aaae7..5433c74958 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1121,7 +1121,8 @@ walletErrorText = \case 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" + WEAccountNotHeld -> "this profile does not hold this account" + WECounterUnknown -> "the next account is unknown after an import, scan the chain first" WEIndexTooLarge -> "account index is too large to harden" WEDerivation e -> "derivation failed: " <> T.pack e diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index 83bcefd340..d6a76ac496 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -1,7 +1,6 @@ {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE TemplateHaskell #-} --- | The device wallet: one BIP-39 seed, and the hardened BIP-44 accounts @m\/44'\/60'\/n'\/0\/0@ under it. module Simplex.Chat.Wallet ( AccountIndex, AccountKey, @@ -10,9 +9,7 @@ module Simplex.Chat.Wallet newSeedEntropy, entropyFromMnemonic, seedMnemonic, - seedMaster, - renderAccountPath, - deriveAccountKey, + deriveAccount, accountSecret, checkAccountIndex, ) @@ -20,8 +17,10 @@ where import Control.Concurrent.STM import Control.Monad.Except +import Control.Monad.IO.Class (liftIO) import Crypto.Random (ChaChaDRG) import qualified Data.Aeson.TH as JQ +import Data.Bifunctor (bimap, first) import qualified Data.ByteArray as BA import qualified Data.ByteArray.Encoding as BAE import Data.ByteString (ByteString) @@ -31,15 +30,14 @@ import Data.Word (Word32) 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.Encoding.String (strEncode) +import Simplex.Messaging.Eth.Address (addressFromPrivateKey, ethereumPath) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) --- | BIP-44 account index, one per thing the device owns on chain. type AccountIndex = Word32 type AccountKey = S.Secp256k1PrivateKey --- | One derived address, with the index it came from. data WalletAddress = WalletAddress { accountIndex :: AccountIndex, keyPath :: Text, @@ -48,64 +46,50 @@ data WalletAddress = WalletAddress deriving (Show) 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 + = WENoMaster + | WEMasterExists + | WEBadMnemonic + | WEHiddenProfile + | WEAccountBound + | WEAccountNotHeld + | WECounterUnknown + | WEIndexTooLarge + | WEDerivation {derivationError :: String} deriving (Eq, Show) --- | Refuse an index at or above 2^31: BIP-32 would harden it onto another index's key. checkAccountIndex :: AccountIndex -> Either WalletError () -checkAccountIndex n = if n >= B32.hardenedOffset then Left WEIndexTooLarge else Right () +checkAccountIndex n = () <$ accountPath n + +accountPath :: AccountIndex -> Either WalletError [Word32] +accountPath n = maybe (Left WEIndexTooLarge) Right $ ethereumPath n 0 --- | 24 words. No 25th-word passphrase, which would be a second secret to back up. masterStrength :: B39.MnemonicStrength masterStrength = B39.MS256 newSeedEntropy :: TVar ChaChaDRG -> STM BA.ScrubbedBytes -newSeedEntropy g = BA.convert . B39.mnemonicToEntropy <$> B39.randomMnemonic masterStrength g +newSeedEntropy g = 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 + Right $ B39.mnemonicToEntropy m _ -> Left WEBadMnemonic seedMnemonic :: BA.ScrubbedBytes -> Either WalletError Text -seedMnemonic entropy = - bipError . fmap (decodeLatin1 . B39.mnemonicPhrase) . B39.entropyToMnemonic $ entropyBytes entropy +seedMnemonic = bimap WEDerivation (decodeLatin1 . B39.mnemonicPhrase) . B39.entropyToMnemonic --- | Deriving this runs PBKDF2, so it is done once per command. -seedMaster :: BA.ScrubbedBytes -> IO (Either WalletError B32.ExtendedKey) -seedMaster entropy = runExceptT $ do - m <- liftEither . bipError . B39.entropyToMnemonic $ entropyBytes entropy - ExceptT $ bipError <$> B32.masterKey (B39.mnemonicToSeed m "") +deriveAccount :: BA.ScrubbedBytes -> AccountIndex -> IO (Either WalletError (AccountKey, WalletAddress)) +deriveAccount entropy n = runExceptT $ do + path <- liftEither $ accountPath n + m <- liftEither . first WEDerivation $ B39.entropyToMnemonic entropy + master <- ExceptT $ first WEDerivation <$> B32.masterKey (B39.mnemonicToSeed m "") + k <- ExceptT $ fmap B32.xkKey . first WEDerivation <$> B32.derivePath master path + a <- liftIO $ addressFromPrivateKey k + pure (k, WalletAddress {accountIndex = n, keyPath = decodeLatin1 $ B32.renderPath path, address = decodeLatin1 $ strEncode a}) -accountPath :: AccountIndex -> [Word32] -accountPath n = ethereumPath n 0 - -renderAccountPath :: AccountIndex -> Text -renderAccountPath = decodeLatin1 . B32.renderPath . accountPath - -deriveAccountKey :: B32.ExtendedKey -> AccountIndex -> IO (Either WalletError AccountKey) -deriveAccountKey master n = fmap B32.xkKey . bipError <$> B32.derivePath master (accountPath n) - --- | As wallets take it when a key is imported on its own. accountSecret :: AccountKey -> Text accountSecret k = "0x" <> decodeLatin1 (BAE.convertToBase BAE.Base16 $ S.unPrivateKey k) --- | The copy BIP-39 takes is a plain 'ByteString' and is not wiped. -entropyBytes :: BA.ScrubbedBytes -> ByteString -entropyBytes = BA.convert - --- | BIP-32 and BIP-39 report failure as a string, and nothing here retries, so one constructor covers them. -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/PostgresSchemaDump.hs b/tests/PostgresSchemaDump.hs index 0c6305b52d..6809955f57 100644 --- a/tests/PostgresSchemaDump.hs +++ b/tests/PostgresSchemaDump.hs @@ -7,9 +7,9 @@ module PostgresSchemaDump (postgresSchemaDumpTest) where import ChatTests.Utils hiding (it) import Control.Concurrent (threadDelay) import Control.DeepSeq -import Control.Monad (unless, void) +import Control.Monad (forM_, unless, void) import qualified Data.ByteString.Char8 as B -import Data.Maybe (fromJust, isJust, isNothing) +import Data.Maybe (isNothing) import Simplex.Messaging.Agent.Store.Postgres (closeDBStore, createDBStore) import Simplex.Messaging.Agent.Store.Postgres.Common (DBOpts (..)) import qualified Simplex.Messaging.Agent.Store.Postgres.Migrations as Migrations @@ -39,13 +39,13 @@ postgresSchemaDumpTest migrations testDBOpts@DBOpts {connstr, schema = testDBSch st <- createDBStore testDBOpts noDownMigrations (MigrationConfig MCYesUpDown Nothing) >>= \case Right st -> pure st Left e -> error $ show e - mapM_ (testDownMigration st) $ filter (isJust . down) $ drop (length noDownMigrations) migrations + forM_ (drop (length noDownMigrations) migrations) $ \m -> + maybe (Migrations.run st Nothing $ MTRUp [m]) (testDownMigration st m) (toDownMigration m) closeDBStore st whenM (doesFileExist testSchemaPath) $ removeFile testSchemaPath where - testDownMigration st m = do + testDownMigration st m downMigr = do putStrLn $ "down migration " <> name m - let downMigr = fromJust $ toDownMigration m schema <- getSchema testSchemaPath Migrations.run st Nothing $ MTRUp [m] schema' <- getSchema testSchemaPath diff --git a/tests/SchemaDump.hs b/tests/SchemaDump.hs index 814183b15b..d16a14af2c 100644 --- a/tests/SchemaDump.hs +++ b/tests/SchemaDump.hs @@ -10,10 +10,10 @@ import ChatTests.DBUtils import Control.Concurrent.STM import Control.DeepSeq import qualified Control.Exception as E -import Control.Monad (unless, void) +import Control.Monad (forM_, unless, void) import Data.List (sort) import qualified Data.Map.Strict as M -import Data.Maybe (fromJust, isJust, isNothing) +import Data.Maybe (isNothing) import Data.Text (Text) import qualified Data.Text as T import qualified Data.Text.IO as T @@ -81,14 +81,14 @@ testSchemaMigrations :: IO () testSchemaMigrations = withTmpFiles $ do let noDownMigrations = takeWhile (\Migration {down} -> isNothing down) Store.migrations Right st <- createDBStore (DBOpts testDB chatDBFunctions "" False True TQOff) noDownMigrations (MigrationConfig MCError Nothing) - mapM_ (testDownMigration st) $ filter (isJust . down) $ drop (length noDownMigrations) Store.migrations + forM_ (drop (length noDownMigrations) Store.migrations) $ \m -> + maybe (Migrations.run st Nothing True $ MTRUp [m]) (testDownMigration st m) (toDownMigration m) closeDBStore st removeFile testDB whenM (doesFileExist testSchema) $ removeFile testSchema where - testDownMigration st m = do + testDownMigration st m downMigr = do putStrLn $ "down migration " <> name m - let downMigr = fromJust $ toDownMigration m schema <- getSchema testDB testSchema Migrations.run st Nothing True $ MTRUp [m] schema' <- getSchema testDB testSchema diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 99604dcb27..5e1fdc94bb 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -1,12 +1,11 @@ {-# LANGUAGE OverloadedStrings #-} -{-# LANGUAGE PostfixOperators #-} module WalletTests where import ChatClient import ChatTests.DBUtils import ChatTests.Utils -import Control.Monad ((<=<)) +import Control.Monad (void) import qualified Data.ByteArray as BA import qualified Data.ByteArray.Encoding as BAE import Data.ByteString.Char8 (ByteString) @@ -15,7 +14,7 @@ import Data.Char (toUpper) import Data.Either (isRight) import Data.List (nub) import qualified Data.Text as T -import Simplex.Chat.Wallet (AccountIndex, WalletError (..), accountSecret, deriveAccountKey, entropyFromMnemonic, renderAccountPath, seedMaster, seedMnemonic) +import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress (..), WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, seedMnemonic) import qualified Simplex.Messaging.Crypto.BIP39 as B39 import qualified Simplex.Messaging.Crypto.Secp256k1 as S import Simplex.Messaging.Encoding.String (strEncode) @@ -24,59 +23,53 @@ import Simplex.Messaging.Util (safeDecodeUtf8) import Test.Hspec hiding (it) import qualified Test.Hspec as Hspec --- | The standard BIP-39 test vector, 12 words, to check the addresses against another wallet. testPhrase12 :: ByteString testPhrase12 = B.unwords $ replicate 11 "abandon" <> ["about"] --- | The 24 word all-zero-entropy vector, the length the commands take. testPhrase24 :: ByteString testPhrase24 = B.unwords $ replicate 23 "abandon" <> ["art"] seedEntropy :: ByteString -> BA.ScrubbedBytes -seedEntropy phrase = BA.convert . B39.mnemonicToEntropy . either error id $ B39.parseMnemonic phrase +seedEntropy phrase = B39.mnemonicToEntropy . either error id $ B39.parseMnemonic phrase -accountKey :: BA.ScrubbedBytes -> AccountIndex -> IO S.Secp256k1PrivateKey -accountKey entropy n = do - m <- either (error . show) id <$> seedMaster entropy - either (error . show) id <$> deriveAccountKey m n +walletAccount :: BA.ScrubbedBytes -> AccountIndex -> IO (AccountKey, WalletAddress) +walletAccount entropy n = either (error . show) id <$> deriveAccount entropy n -addressOf :: S.Secp256k1PrivateKey -> IO String -addressOf k = B.unpack . strEncode <$> addressFromPrivateKey k - --- | The address a wallet reaches when the secret is imported as a private key. addressFromSecret :: String -> IO String -addressFromSecret secret = - addressOf . either error id =<< S.mkPrivateKey (either error id $ BAE.convertFromBase BAE.Base16 (B.drop 2 $ B.pack secret)) +addressFromSecret secret = do + k <- either error id <$> S.mkPrivateKey (either error id $ BAE.convertFromBase BAE.Base16 (B.drop 2 $ B.pack secret)) + B.unpack . strEncode <$> addressFromPrivateKey k --- | An @export account@ row: the index, the path, the address, the secret. exportRow :: HasCallStack => String -> (String, String, String, String) exportRow row = case words row of - [idx, path, address, secret] -> (idx, path, address, secret) + [idx, path, addr, secret] -> (idx, path, addr, secret) _ -> error $ "unexpected export row: " <> row +accountBound :: HasCallStack => TestCC -> String -> Expectation +accountBound cc idx = (take 1 . words <$> getTermLine cc) `shouldReturn` [idx] + walletDerivationTests :: Spec walletDerivationTests = do Hspec.it "accounts are the accounts another wallet derives for the same phrase" $ do - let addrOf n = addressOf =<< accountKey (seedEntropy testPhrase12) n - -- Ledger Live accounts 1 and 2 for this phrase, the published values for it + let addrOf n = address . snd <$> walletAccount (seedEntropy testPhrase12) n addrOf 0 `shouldReturn` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" addrOf 1 `shouldReturn` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265" Hspec.it "the exported secret is the one another wallet shows for that account" $ do - k <- accountKey (seedEntropy testPhrase12) 0 - -- as a wallet shows it for m/44'/60'/0'/0/0 of this phrase + (k, _) <- walletAccount (seedEntropy testPhrase12) 0 accountSecret k `shouldBe` "0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727" Hspec.it "every account has its own address" $ do - let entropy = seedEntropy testPhrase12 - addrs <- mapM (addressOf <=< accountKey entropy) [0 .. 9] + addrs <- mapM (fmap (address . snd) . walletAccount (seedEntropy testPhrase12)) [0 .. 9] length (nub addrs) `shouldBe` 10 Hspec.it "a secret whose first byte is zero keeps its 64 hex digits" $ do k <- either error id <$> S.mkPrivateKey (BA.convert $ B.pack ('\0' : replicate 31 '\1')) let secret = T.unpack $ accountSecret k take 4 secret `shouldBe` "0x00" 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 "renders the path an account is derived at" $ do + (keyPath . snd <$> walletAccount (seedEntropy testPhrase12) 0) `shouldReturn` "m/44'/60'/0'/0/0" + (keyPath . snd <$> walletAccount (seedEntropy testPhrase12) 7) `shouldReturn` "m/44'/60'/7'/0/0" + Hspec.it "refuses an index BIP-32 cannot harden" $ + (void <$> deriveAccount (seedEntropy testPhrase12) 2147483648) `shouldReturn` Left WEIndexTooLarge Hspec.it "round-trips the phrase it was imported from" $ seedMnemonic (seedEntropy testPhrase24) `shouldBe` Right (safeDecodeUtf8 testPhrase24) Hspec.it "takes 24 words only, with a valid checksum" $ do @@ -96,33 +89,30 @@ walletTests = do it "will not take a new account on an imported phrase" testWalletImport it "the wallet, the accounts and the counter come back after a restart" testWalletPersists it "deletes the wallet, and one can be made again" testWalletDelete - it "a hidden profile is bound no account" testWalletHiddenProfile - it "will not export an account another profile holds" testWalletExportNotMine + it "does not bind an account to a hidden profile" testWalletHiddenProfile + it "exports only an account the profile holds" testWalletExportNotHeld it "refuses an index BIP-32 cannot harden, on every command" testWalletIndexTooLarge testWalletCreate :: HasCallStack => TestParams -> IO () testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> "/_wallet" - alice <## "no wallet on this device" - -- reading creates nothing - alice ##> "/_wallet" + alice ##> "/_wallet 1" alice <## "no wallet on this device" alice ##> "/_wallet export master" alice <## "wallet: this device has no wallet" - alice ##> "/_wallet bind" + alice ##> "/_wallet bind 1" alice <## "wallet: this device has no wallet" alice ##> "/_wallet delete" alice <## "wallet: this device has no wallet" + alice ##> "/_wallet 1" + alice <## "no wallet on this device" alice ##> "/_wallet create new" alice <## "wallet, no accounts for this profile" alice ##> "/_wallet create new" 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" - -- a phrase is taken as a backup card writes it, case and all alice ##> ("/_wallet create mnemonic=" <> map toUpper (B.unpack testPhrase24)) alice <## "wallet, no accounts for this profile" alice ##> "/_wallet export master" @@ -132,85 +122,77 @@ 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 ##> "/_wallet bind 1" + alice `accountBound` "0" + alice ##> "/_wallet bind 1" + alice `accountBound` "1" + alice ##> "/_wallet bind 1 account=0" + alice `accountBound` "0" + alice ##> "/_wallet 1" alice <## "accounts: 0, 1" 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 ##> "/_wallet bind 1" + alice `accountBound` "0" alice ##> "/create user alisa" showActiveUser alice "alisa" - -- the wallet is the device's, the accounts are the profile's - alice ##> "/_wallet" + alice ##> "/_wallet 2" alice <## "wallet, no accounts for this profile" - alice ##> "/_wallet bind" - alice <## "accounts: 1" - alice ##> "/_wallet bind account=0" + alice ##> "/_wallet bind 2" + alice `accountBound` "1" + alice ##> "/_wallet bind 2 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 ##> "/_wallet bind 1 account=2" + alice `accountBound` "2" + alice ##> "/_wallet bind 1" + alice `accountBound` "3" + alice ##> "/_wallet bind 1 account=1" + alice `accountBound` "1" + alice ##> "/_wallet 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" + alice ##> "/_wallet bind 1" + alice `accountBound` "4" testWalletDeletedProfileAccount :: HasCallStack => TestParams -> IO () testWalletDeletedProfileAccount 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 ##> "/_wallet bind 1" + alice `accountBound` "0" alice ##> "/create user alisa" showActiveUser alice "alisa" - -- deleting a profile does not take its account with it alice ##> "/delete user alice" alice <### ["ok", "completed deleting user"] - alice ##> "/_wallet" + alice ##> "/_wallet 2" alice <## "wallet, no accounts for this profile" - -- and another profile can take it, which is how a name outlives its profile - alice ##> "/_wallet bind account=0" - alice <## "accounts: 0" + alice ##> "/_wallet bind 2 account=0" + alice `accountBound` "0" 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 + getTermLine alice `shouldReturn` 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 zero-padded index is the same index - alice ##> "/_wallet address account=0000000003" + alice ##> "/_wallet address account=00000000003" getTermLine alice `shouldReturn` at3 - -- a malformed index is a parse error, never a silent bind of the next account - alice ##> "/_wallet bind account=abc" + alice ##> "/_wallet bind 1 account=abc" alice <## "bad chat command: Failed reading: empty" - alice ##> "/_wallet" + alice ##> "/_wallet 1" alice <## "wallet, no accounts for this profile" testWalletExport :: HasCallStack => TestParams -> IO () @@ -219,74 +201,68 @@ testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice <## "wallet, no accounts for this profile" alice ##> "/_wallet export master" alice <## B.unpack testPhrase24 - alice ##> "/_wallet export account 0" - (idx, path, address, secret) <- exportRow <$> getTermLine alice + alice ##> "/_wallet bind 1 account=0" + alice `accountBound` "0" + alice ##> "/_wallet bind 1 account=1" + alice `accountBound` "1" + alice ##> "/_wallet export account 1 0" + (idx, path, addr, secret) <- exportRow <$> getTermLine alice idx `shouldBe` "0" path `shouldBe` "m/44'/60'/0'/0/0" - -- m/44'/60'/0'/0/0 of the 24 word vector, pinned so a change of path fails here - address `shouldBe` "0xF278cF59F82eDcf871d630F28EcC8056f25C1cdb" - addressFromSecret secret `shouldReturn` address - -- the index reaches the key, not only the path printed beside it - alice ##> "/_wallet export account 1" - (idx', path', address', _) <- exportRow <$> getTermLine alice + addr `shouldBe` "0xF278cF59F82eDcf871d630F28EcC8056f25C1cdb" + addressFromSecret secret `shouldReturn` addr + alice ##> "/_wallet export account 1 1" + (idx', path', addr', _) <- exportRow <$> getTermLine alice idx' `shouldBe` "1" path' `shouldBe` "m/44'/60'/1'/0/0" - (addressOf =<< accountKey (seedEntropy testPhrase24) 1) `shouldReturn` address' - -- and an address is read from the account the command names, not the counter + (T.unpack . address . snd <$> walletAccount (seedEntropy testPhrase24) 1) `shouldReturn` addr' alice ##> "/_wallet address account=1" - addressRow <- words <$> getTermLine alice - addressRow `shouldBe` ["1", "m/44'/60'/1'/0/0", address'] + (words <$> getTermLine alice) `shouldReturn` ["1", "m/44'/60'/1'/0/0", addr'] testWalletImport :: HasCallStack => TestParams -> IO () testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do 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 bind 1" + alice <## "wallet: the next account is unknown after an import, scan the chain 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" + alice <## "wallet: the next account is unknown after an import, scan the chain first" + alice ##> "/_wallet bind 1 account=4" + alice `accountBound` "4" + alice ##> "/_wallet bind 1" + alice <## "wallet: the next account is unknown after an import, scan the chain first" 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 bind 1 account=2" + alice `accountBound` "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 ##> "/_wallet 1" alice <## "accounts: 2" alice ##> "/_wallet export master" alice <## phrase - -- the counter came back too, so no account is handed out a second time - alice ##> "/_wallet bind" - alice <## "accounts: 2, 3" + alice ##> "/_wallet bind 1" + alice `accountBound` "3" testWalletDelete :: HasCallStack => TestParams -> IO () testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create new" alice <## "wallet, no accounts for this profile" - alice ##> "/_wallet bind account=1" - alice <## "accounts: 1" + alice ##> "/_wallet bind 1 account=1" + alice `accountBound` "1" alice ##> "/_wallet delete" alice <## "ok" - alice ##> "/_wallet" + alice ##> "/_wallet 1" alice <## "no wallet on this device" alice ##> "/_wallet create new" alice <## "wallet, no accounts for this profile" - -- the new wallet holds no account and its counter starts over - alice ##> "/_wallet bind" - alice <## "accounts: 0" + alice ##> "/_wallet bind 1" + alice `accountBound` "0" testWalletHiddenProfile :: HasCallStack => TestParams -> IO () testWalletHiddenProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -298,42 +274,36 @@ testWalletHiddenProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> alice <## "current user alisa:" alice <## "messages are hidden (use /tail to view)" alice <## "profile is hidden" - alice ##> "/_wallet bind" + alice ##> "/_wallet bind 2" alice <## "wallet: a hidden profile cannot own an account" -testWalletExportNotMine :: HasCallStack => TestParams -> IO () -testWalletExportNotMine ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do +testWalletExportNotHeld :: HasCallStack => TestParams -> IO () +testWalletExportNotHeld ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create new" alice <## "wallet, no accounts for this profile" - alice ##> "/_wallet bind" - alice <## "accounts: 0" - -- the profile's own account is its to export - alice ##> "/_wallet export account 0" + alice ##> "/_wallet bind 1" + alice `accountBound` "0" + alice ##> "/_wallet export account 1 0" (_, path, _, _) <- exportRow <$> getTermLine alice path `shouldBe` "m/44'/60'/0'/0/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" - (_, path', _, _) <- exportRow <$> getTermLine alice - path' `shouldBe` "m/44'/60'/7'/0/0" + alice ##> "/_wallet export account 2 0" + alice <## "wallet: this profile does not hold this account" + alice ##> "/_wallet export account 2 7" + alice <## "wallet: this profile does not hold this account" 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 bind 1 account=2147483648" alice <## "wallet: account index is too large to harden" - alice ##> "/_wallet export account 2147483648" + alice ##> "/_wallet export account 1 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 bind 1 account=2147483647" + alice `accountBound` "2147483647" + alice ##> "/_wallet bind 1" alice <## "wallet: account index is too large to harden" From 24ef8bf1f57ae4880ad844403afa0fd16121a5ca Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 24 Sep 2026 15:47:36 +0200 Subject: [PATCH 53/67] self-review iteration 3 --- cabal.project | 2 +- docs/rfcs/2026-09-10-wallet-keys.md | 131 +++++++++--------- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Library/Commands.hs | 8 +- .../Migrations/M20260924_wallet_seeds.hs | 2 +- .../Migrations/M20260924_wallet_seeds.hs | 2 +- .../SQLite/Migrations/chat_query_plans.txt | 15 +- src/Simplex/Chat/Store/Wallets.hs | 27 ++-- src/Simplex/Chat/View.hs | 4 +- src/Simplex/Chat/Wallet.hs | 3 +- tests/WalletTests.hs | 42 +++--- 11 files changed, 119 insertions(+), 119 deletions(-) diff --git a/cabal.project b/cabal.project index abb3a7d37e..95fbeeeb88 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 714803f7a00f51379621b131ffa561d7b61c26d2 + tag: 256ef554486e5de67401c3d9d0e5ce2c35402247 source-repository-package type: git diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 0ab352b65f..0e55941c8e 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -2,36 +2,36 @@ ## Problem -The client has no static keys of its own which could be used for blockchain use cases. It needs them at addresses it can derive again after a restart, after a database restore, or on a new device, or whatever those addresses hold is lost with the device. +The client has no static keys of its own for blockchain use cases. It requires keys at addresses it can derive again after a restart, after a database restore, or on a new device; otherwise whatever those addresses hold is lost with the device. -The first consumer is [public namespaces](./2026-05-21-public-namespaces.md): a name is a record on a public blockchain saying which address owns it, and every name gets an account of its own, so one chat profile needs many accounts. This is not a names wallet only, though. An account is an ordinary Ethereum account, and [stealth transfers](https://github.com/simplex-chat/simplex-chat/pull/7519) will take accounts the same way. A device also holds several chat profiles, which are meant to stay isolated, and the one phrase the user writes down has to recover all of them. +The first consumer is [public namespaces](./2026-05-21-public-namespaces.md): a name is a record on a public blockchain that contains the owner's address, and every name is owned by an account of its own, so one chat profile requires many accounts. The wallet is not specific to names. An account is an ordinary Ethereum account, and [stealth transfers](https://github.com/simplex-chat/simplex-chat/pull/7519) will use accounts in the same way. A device also contains several chat profiles, which are meant to stay isolated, and the one phrase the user writes down has to recover all of them. -Out of scope of this doc: buying a name, the names protocol, the registrar, signing, the scanning of the chain and the command that records what it finds, importing an account key that the master does not derive, more than one master, and stealth keys. Buying is not implemented yet either, so the keys land first and can be reviewed on their own. +Out of scope of this doc: buying a name, the names protocol, the registrar, signing, the chain scan and the command that records its results, importing an account key that the master does not derive, more than one master, and stealth keys. Buying is not implemented yet either, so key derivation is merged first and can be reviewed on its own. ## Security objectives 1. Every key the device uses is derivable again from the master phrase alone. -2. Handing over one account key hands over that account and nothing else, whatever else the holder has. +2. Giving away one account key gives away that account and nothing else, whatever else the recipient has. 3. No extended public key links two accounts. -4. Key material is made and handed out only when asked for, never at startup and never as a side effect of reading. -5. A hidden profile owns nothing on chain, so nothing on chain is tied to it. +4. Key material is generated or derived only by an explicit command, never at startup and never as a side effect of a read. +5. A hidden profile owns nothing on chain, so nothing on chain is linked to it. -There is no unit between the two: the master phrase is every account, and an account secret is one account. Nothing hands over "this profile's accounts", because nothing in the derivation knows about profiles. +The master phrase derives every account and an account secret controls one account; no export covers anything in between, such as "this profile's accounts", because the profile is not an input to the derivation. ## Design ### Accounts -A device has one piece of [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) entropy, the **master**, made as 24 words. Nothing signs with it. Every key is a [BIP-44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki) account under it, at a hardened account index, which is how Ledger Live lays out an Ethereum wallet. +A device has one piece of [BIP-39](https://github.com/bitcoin/bips/blob/master/bip-0039.mediawiki) entropy, the **master**, generated as 24 words. Nothing signs with it. Every key is a [BIP-44](https://github.com/bitcoin/bips/blob/master/bip-0044.mediawiki) account under it, at a hardened account index, which is how Ledger Live lays out an Ethereum wallet. -Each thing the device owns on chain is given an **account index**, and the key there is the **account key**. Names are the first use: one name, one account, and that account's address is what the name record names as its owner. Which chat profile an account belongs to is a mapping in the database. No path mentions a profile, so nothing about a profile reaches the derivation and the two can be rearranged without a key moving. +Each thing the device owns on chain is assigned an **account index**, and the key at that index is the **account key**. Names are the first use: one name, one account, and that account's address is the owner address in the name record. Which chat profile an account belongs to is a mapping in the database. No path contains a profile, so no profile data is an input to the derivation, and the mapping can be changed without changing any key. ``` master seed, 24 words the only thing to back up └── m/44'/60'/n'/0/0 account n, n >= 0 ``` -An account index is BIP-44's own account level, the level [Ledger Live](https://github.com/LedgerHQ/ledger-live-common/blob/HEAD/docs/derivation.md) varies and also calls an account, numbering from one where its Account 1 is index 0. So the master phrase given to another wallet reaches the same addresses and the two tools use the word for the same thing. That wallet finds account 0 and stops, because BIP-44 says to stop at the first account with no transaction history and an account that only owns a name never transacts; above it the user has to enter the path. +An account index is BIP-44's own account level, the level [Ledger Live](https://github.com/LedgerHQ/ledger-live-common/blob/HEAD/docs/derivation.md) varies and also calls an account, numbering from one where its Account 1 is index 0. So the master phrase imported into another wallet derives the same addresses, and the two tools use the word for the same thing. That wallet discovers account 0 and stops, because BIP-44 specifies that discovery stops at the first account with no transaction history and an account that only owns a name has none; above it the user has to enter the path. The tests pin the derivation against the standard `abandon ... about` test mnemonic, which is 12 words, with an empty BIP-39 passphrase. Addresses are in [EIP-55](https://eips.ethereum.org/EIPS/eip-55) mixed case. @@ -42,81 +42,81 @@ account 1 m/44'/60'/1'/0/0 0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265 ### Why an account for each name -The alternative is one account holding several names. A name's owner address is public, so that address would link them, and that is as true inside one profile as across two. An account for each name leaves no such link, and the same holds for anything else an account comes to own. +The alternative is one account owning several names. A name's owner address is public, so that address would link them, and that is as true inside one profile as across two. An account for each name creates no such link, and the same holds for anything else an account comes to own. ### Why the account level is hardened -The alternative is BIP-44's ordinary address level, `m/44'/60'/0'/0/n`, which is what MetaMask enumerates and is therefore the friendlier path. It is not hardened, and [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) has a trap there: the extended public key above a key, together with that one key's private half, yields the parent private key and from it every sibling. Handing out one account key is one half, and any wallet that enumerates accounts produces the other. The two steps below an account are not hardened, so those halves do reach that account's own key, but nothing else is derived under an account and the account level itself is hardened, so they stop there and reach no other account. That is objectives 2 and 3, and it is worth the loss of MetaMask's default path. +The alternative is BIP-44's ordinary address level, `m/44'/60'/0'/0/n`, which is what MetaMask enumerates and is therefore the friendlier path. It is not hardened, and [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) has a known weakness there: the extended public key of a parent, together with one non-hardened child's private key, yields the parent private key and from it every sibling. An exported account key is one half, and any wallet that enumerates accounts produces the other. The two levels below an account are not hardened, so the two halves together reveal that account's key; nothing else is derived under an account and the account level itself is hardened, so they reveal no other account's key. This satisfies objectives 2 and 3, and it is worth the loss of MetaMask's default path. ### Why 24 words -Entropy cannot be added to a seed afterwards and what a seed is used for can grow, so the master is 24 words, 256 bits, which is a deliberate choice rather than a default. +Entropy cannot be added to a seed afterwards and the uses of a seed can grow, so the master is 24 words, 256 bits. ## Profiles and accounts -An account is bound to at most one chat profile, and a profile to any number of accounts, because a profile can own any number of names. Only one of those names points at the profile as its SimpleX domain name, but which one is the names layer's record, not the wallet's: the wallet says only which profile an account belongs to. +An account is bound to at most one chat profile, and a profile to any number of accounts, because a profile can own any number of names. Only one of those names points at the profile as its SimpleX domain name, but which one is recorded by the names layer, not by the wallet: the wallet records only which profile an account belongs to. -Accounts are handed out in order and never reused, because an account the device no longer tracks still owns whatever it holds, and an account can sit unbound. Nothing is bound when a profile is made; an account is taken on first use, when a profile buys a name, so a user who never buys anything has a device that has never derived a key. +Accounts are allocated in order and never reused, because an account the device no longer tracks still owns whatever it holds, and an account can remain unbound. Nothing is bound when a profile is created; an account is bound on first use, when the user buys a name for a profile, so a device on which the user never buys anything has never derived a key. -A hidden profile is bound no account, so it cannot own a name. Two things would leak: the master derives every account, so unlocking any profile also derives a hidden profile's account keys; and a name is written into the profile's own database row and listed across the device, while a hidden profile is a filter on what is shown, not encryption. Closing either is work in the profiles and in the name record, not in the key layout. Incognito is a property of a connection in this app rather than of a profile, so there is nothing at this level to refuse; an incognito connection has no profile of its own to bind an account to. +No account is bound to a hidden profile, so a hidden profile cannot own a name. Two things would leak: the master derives every account, so whoever can use any profile can also derive a hidden profile's account keys; and a name is written into the profile's own database row and listed across the device, while a hidden profile is a filter on what is shown, not encryption. Closing either requires changes in the profiles and in the name record, not in the key layout. Incognito is a property of a connection in this app rather than of a profile, so there is nothing at this level to reject; an incognito connection has no profile of its own to bind an account to. ## Commands -An internal API, called by the names commands and by whatever else takes accounts later, rather than typed by users. Nothing here runs at startup or as a side effect of reading. +These commands are an internal API, called by the names commands and by any later feature that binds accounts, rather than typed by users. Nothing here runs at startup or as a side effect of a read. ``` /_wallet whether the device has a master, and the - accounts the profile is bound to + accounts bound to the profile /_wallet create new generate the master, 24 words -/_wallet create mnemonic= take the master entropy from a phrase, 24 words +/_wallet create mnemonic= import the master entropy from a phrase, 24 words /_wallet bind account= bind an account to the profile and return its address; without account=, the next free one -/_wallet address account= one address, with the account index it came - from; without account=, the next free one +/_wallet address account= one address, with the account index it is + derived at; without account=, the next free one /_wallet export master the master phrase /_wallet export account the secret of an account the profile holds, 0x and 64 hex /_wallet delete delete the master entropy and its accounts ``` -A command that acts on a profile's accounts names the profile and is refused when that profile is not the active one, so switching profiles during a flow cannot bind an account to the wrong profile. +A command that acts on a profile's accounts names the profile and is rejected when that profile is not the active one, so switching profiles during a flow cannot bind an account to the wrong profile. -`create` always names its source, so no key material is made by typing a prefix. An imported phrase must be 24 BIP-39 English words with a valid checksum, normalised for case and spacing. There is no BIP-39 passphrase, because it would be a second secret to back up and losing it would look exactly like losing the phrase, so a phrase used with one elsewhere lands on a different tree here and finds nothing. +`create` always names its source, so no key material is generated by typing a prefix. An imported phrase must be 24 BIP-39 English words with a valid checksum, normalised for case and spacing. There is no BIP-39 passphrase, because it would be a second secret to back up and losing it would look exactly like losing the phrase, so a phrase used with a passphrase elsewhere derives different keys here. -`bind` without an argument takes the next free account from a counter on the master, which is a high-water mark and not a count of what is held, and returns the bound account's address, so the caller knows which account it received. Named with `account=` it takes that one, which is how an account found by a scan is attached to the profile that should have it, and it is refused for an account another profile holds. After an import the counter is unknown rather than zero, because the phrase does not say how many accounts it has been used for, so taking a new one is refused until a scan sets it, while binding a known account is still allowed. +`bind` without an argument binds the account at a counter on the master, which is a high-water mark and not a count of bound accounts, and returns the bound account's index, path and address. With `account=` it binds that account, which is how an account found by a scan is attached to the profile it belongs to, and it is rejected for an account another profile holds. After an import the counter is unknown rather than zero, because the phrase does not encode how many accounts it has been used for, so binding the next account is rejected until a scan sets the counter, while binding a known account is still allowed. -BIP-32 hardens an index by adding 2^31, so an index at or above 2^31 is already a hardened component and derives the same key as the index it wraps onto: account 2^31 is account 0. That is a collision, not a loss of hardening, and it would put one key under two account indexes. Every index this API takes is refused there, including one read from the counter, and the columns carry that bound so that whatever writes them later cannot slip past it. The counter's bound is one higher than an account's, because it holds the next index to hand out, and 2^31 there means every account that can be hardened has been handed out. +BIP-32 marks an index as hardened by setting its top bit, so an index at or above 2^31 already has that bit set and derives the same key as the index 2^31 below it: account 2^31 is account 0. That is a collision, not a loss of hardening, and it would put one key under two account indexes. Every index at or above 2^31 is rejected, including one read from the counter; the simplexmq function that builds the path rejects it too, and the columns have CHECK constraints for that bound, so later writes cannot exceed it. The counter's bound is one higher than an account's, because it contains the next index to bind, and 2^31 there means the counter has passed every index that can be hardened. -`address` reads the counter without moving it, so asking twice gives the same answer, and it works for an account the database has no row for, which is what a device that lost its database needs. One address at a time is enough: a caller scanning the tree loops itself. +`address` reads the counter without changing it, so two calls return the same address, and it derives an address for an account the database has no row for, which a device that lost its database requires. One address per call is sufficient: a caller that scans the tree calls it in a loop. -`export account` is refused unless the profile holds the account, so every exported key belongs to an account the counter has already passed, and no later `bind` returns it as a new account. An export is a copy and not a handover: the device still derives what it exported and can still sign with it, so giving an account key away leaves two parties able to act as its owner until whatever it holds is transferred on chain. Signing is not in this change, and when it is added it is a command here that signs and returns a signature, not `export account` followed by signing elsewhere, which would make the narrow export the ordinary path. `delete` leaves accounts registered to their addresses, reachable only by the phrase. +`export account` is rejected unless the profile holds the account, so every exported key belongs to an account that is already bound, which `bind` without an index never returns: the counter is either past it or unknown. An export is a copy and not a transfer: the device still derives what it exported and can still sign with it, so giving an account key away leaves two parties able to act as its owner until whatever it holds is transferred on chain. Signing is not in this change, and when it is added it is a command here that signs and returns a signature, not `export account` followed by signing elsewhere, which would make the narrow export the ordinary path. `delete` leaves accounts registered to their addresses, recoverable only from the phrase. ```haskell data WalletAddress = WalletAddress {accountIndex :: Word32, keyPath :: Text, address :: Text} data WalletError = WENoMaster -- the device has no master entropy - | WEMasterExists -- create, when it already has one + | WEMasterExists -- create, when the device already has one | WEBadMnemonic -- wrong word count, wrong word, or bad checksum - | WEHiddenProfile -- bind, on a profile the app hides + | WEHiddenProfile -- bind, on a hidden profile | WEAccountBound -- bind, on an account another profile holds | WEAccountNotHeld -- export account, on an account the profile does not hold - | WECounterUnknown -- no counter to read yet, after an import + | WECounterUnknown -- the counter is not set yet, after an import | WEIndexTooLarge -- at or above 2^31 | WEDerivation {derivationError :: String} -- BIP-32 or BIP-39 derivation failed ``` ## Recovery -Three cases, by how much of the database came back. +Three cases, by how much of the database is restored. -**Only the phrase is left.** A phrase carries entropy and nothing else. Which accounts were used is not in it, so the counter starts out unknown and only a scan of the chain sets it; which profile held which account is not in it either, and nothing recovers that. The scan is a walk over `address`, one call for each candidate: account 0, then 1, and so on, asking what each address owns and stopping after a run that owns nothing. Asking what an address owns, deciding how long that run has to be, and recording the answers belong to the caller, because the wallet never sees an answer. Nothing outside this API writes these tables, so a command landing with the scan writes a row for each account found, unbound, and moves the counter past them. +**Only the phrase is left.** A phrase encodes the entropy and nothing else. Which accounts were used is not encoded in it, so the counter starts out unknown and only a scan of the chain sets it; which profile held which account is not encoded in it either, and nothing recovers that. The scan is a sequence of `address` calls, one for each candidate: account 0, then 1, and so on, querying what each address owns and stopping after a run of addresses that own nothing. Querying what an address owns, choosing the length of that run, and recording the results are the caller's responsibility, because the wallet never receives the results. Nothing outside this API adds rows to these tables, so a command added together with the scan writes a row for each account found, unbound, and moves the counter past every account row, including accounts bound by index that the scan does not find. -What the scan finds is unbound, and the user attaches each account to a profile with `bind account=`. The names those accounts own are what identify them, which is what makes the question answerable at all: the user is choosing between names they recognise, not between numbers. Binding moves no key and signs nothing, because ownership does not change, only which profile the app shows the account under. Pointing a name at that profile's address is a separate signed edit of the name's record. +What the scan finds is unbound, and the user attaches each account to a profile with `bind account=`. The names those accounts own identify them, which is what makes the choice possible at all: the user chooses between names they recognise, not between numbers. Binding changes no key and signs nothing, because ownership does not change, only the profile under which the app shows the account. Pointing a name at that profile's address is a separate signed edit of the name's record. -**The database is older than the master.** It has the accounts as of the backup and nothing written after it, so its counter is behind. A counter that is behind is worse than one that is unknown, because it looks usable and hands out an account the master has already used. Nothing here can tell a restored database from a current one, so clearing the counter belongs to whatever restores one, along with the scan that fills it in again. +**The database is restored from a backup.** It contains the accounts as of the backup and nothing written after it, so its counter is behind. A counter that is behind is worse than one that is unknown, because it appears valid, so `bind` returns an account that is already in use. Nothing here distinguishes a restored database from a current one, so clearing the counter is the responsibility of whatever restores a database, along with the scan that sets it again. -**The database is current.** It records which profile holds which account, so nothing is asked of the user. +**The database is current.** It records which profile holds which account, so the user is asked nothing. ## Schema @@ -126,64 +126,63 @@ CREATE TABLE wallet_seeds ( entropy BLOB NOT NULL CHECK (length(entropy) = 32), next_account_index INTEGER CHECK (next_account_index BETWEEN 0 AND 2147483648), -- null means not known yet single_seed INTEGER NOT NULL DEFAULT 1 -); +) STRICT; CREATE TABLE wallet_accounts ( wallet_account_id INTEGER PRIMARY KEY AUTOINCREMENT, wallet_seed_id INTEGER NOT NULL REFERENCES wallet_seeds ON DELETE CASCADE, account_index INTEGER CHECK (account_index BETWEEN 0 AND 2147483647), -- null when the key was imported user_id INTEGER REFERENCES users ON DELETE SET NULL -); +) STRICT; CREATE UNIQUE INDEX idx_wallet_seeds_single_seed ON wallet_seeds(single_seed); CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_accounts(wallet_seed_id, account_index); CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id); ``` -Only entropy that nothing can derive is stored: the master, always 32 bytes, since it is made and imported as 24 words. An account key is never stored, because the master entropy and an account index derive it whenever one is needed. So `wallet_accounts` holds what derivation cannot produce, which account indexes the device knows about and which profile each belongs to. A row with no `user_id` is an account no profile holds, which is what a deleted chat profile leaves behind and what a scan writes. +Only entropy that nothing can derive is stored: the master, always 32 bytes, since it is generated and imported as 24 words. An account key is never stored, because the master entropy and an account index derive it whenever it is required. So `wallet_accounts` contains what derivation cannot produce: which account indexes are recorded on the device and which profile each belongs to. A row with no `user_id` is an account no profile holds, which is the result of deleting a chat profile and what a scan writes. -`users` is not touched: the mapping lives on the account row, and the index on `user_id` is not unique, because a profile owns as many accounts as it owns names. One seed per device is `single_seed` and the unique index on it, which a later change lifts with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop one of those without rebuilding the table. Deleting the master takes its account rows, because an account index with no entropy behind it derives nothing. The migration has no reverse step, because reversing it would drop the only copy of the master entropy. A reverse step runs when an older app opens a newer database and the user confirms "Downgrade and open chat", and the backup made then is overwritten by the next upgrade. With no reverse step the older app reports that the database is newer than it is, and changes nothing. +`users` is not changed: the mapping is stored in the account row, and the index on `user_id` is not unique, because a profile owns as many accounts as it owns names. One seed per device is enforced by `single_seed` and the unique index on it, which a later change removes with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop an inline constraint without rebuilding the table. Deleting the master deletes its account rows, because an account index without its entropy derives nothing. The migration has no down migration, because a down migration would delete the master entropy, which may have no other copy. A down migration runs when an older app opens a newer database and the user confirms "Downgrade and open chat", and the backup made then is overwritten by the next upgrade. Without a down migration the older app reports that the database is newer than the app, and changes nothing. -A null `account_index` marks an account whose key was imported rather than derived, which the master phrase does not recover and the schema must not suggest it does. Importing one is not implemented here; the column is nullable now so that a row written later reads correctly. That feature also requires storage for the imported secret and an optional link to a seed, because such an account belongs to no seed and must not be deleted with one; on SQLite, making `wallet_seed_id` nullable rebuilds the table. +A null `account_index` marks an account whose key was imported rather than derived, which the master phrase does not recover and the schema must not suggest it does. Importing one is not implemented here; the column is nullable now so that a row written later is read correctly. That feature also requires storage for the imported secret and an optional link to a seed, because such an account belongs to no seed and must not be deleted with one; on SQLite, making `wallet_seed_id` nullable rebuilds the table. ## Threat model -- **Someone with the database file.** Gets everything, now and later: the stored entropy is the master phrase in another encoding, so an archive exported to move devices carries every key on the device. No export granularity helps against a file copy. On SQLite the connection sets `secure_delete`, so a deleted row's pages are zeroed; the journal and any copy already taken are not. -- **Someone with one account key.** Can act as that account's owner permanently, because an export is a copy and the device keeps deriving the same key. Cannot reach another account. -- **A wallet the master phrase is imported into.** Enumerating BIP-44 accounts computes account extended public keys, and some wallets send them to a vendor, which hands that vendor every account on the device at once, across every profile. That is what an account for each name otherwise prevents. -- **Whoever answers the recovery scan.** Sees every address the phrase could hold a name on, in one burst, so it links every account on the device, across profiles, and recognises addresses that hold nothing yet, which is where future accounts will be. `address` derives for any index straight from the master, so a caller can enumerate hidden profiles' addresses too. This is the sharpest cost in the design. -- **A paired device.** Can run any of these commands, because they are not blocked from one: `export master` reads the whole wallet, `create` on a device that has none plants a seed the pairing controls, and `delete` destroys the only copy. Blocking `ExecChatStoreSQL` while allowing `export master` is not a coherent line, and the wallet commands need their own decision rather than the catch-all. -- **Someone reading the logs.** The core logs no command and no answer, so a phrase typed into `create` reaches no log. -- **A page open in the user's browser.** The websocket server in `apps/simplex-chat/Server.hs` accepts any local connection, asks for no token and checks no `Origin`, and websockets are not bound by the same origin policy, so any page loaded while that server runs can send `export master` and read the answer. It also prints every command it receives, that phrase included. Both are properties of that server, which this change gives something worth taking, and closing them is work there rather than in the wallet. +- **Someone with the database file.** Gets everything, now and later: the stored entropy is the master phrase in another encoding, so an archive exported to move devices contains every key on the device. No export granularity protects against a file copy. On SQLite the connection sets `secure_delete`, so a deleted row's pages are zeroed; the journal and any copy already made are not. +- **Someone with one account key.** Can act as that account's owner permanently, because an export is a copy and the device keeps deriving the same key. Cannot derive another account's key. +- **A wallet the master phrase is imported into.** Enumerating BIP-44 accounts computes account extended public keys, and some wallets send them to a vendor, which gives that vendor every account on the device at once, across every profile. That is what an account for each name otherwise prevents. +- **Whoever answers the recovery scan.** Receives every address the scan derives from the phrase, in one sequence of requests, so it can link every account on the device, across profiles, and recognise addresses that own nothing yet, which is where future accounts will be. `address` derives an address for any index directly from the master, so a caller can enumerate hidden profiles' addresses too. This is the largest privacy cost of the design. +- **A paired device.** Wallet commands are allowed from a paired device like other chat commands: `export master` returns the whole wallet, `create` on a device that has no seed creates a seed chosen by the paired device, and `delete` deletes the master entropy, which may have no other copy. +- **Someone reading the logs.** The core logs no command and no response, so a phrase passed to `create` is not written to any log. +- **A page open in the user's browser.** The websocket server in `apps/simplex-chat/Server.hs`, which runs only with `--chat-server-port` and listens on 127.0.0.1, accepts any local connection, requires no token and checks no `Origin`, and websockets are not bound by the same origin policy, so any page loaded while that server runs can send `export master` and read the response. It also prints every command it receives, that phrase included. Both are properties of that server, which this change makes a more valuable target, and closing them requires changes to that server rather than to the wallet. ## Known limits -1. **There is no per-profile handover.** The master phrase is every account on the device and an account secret is one account, with nothing in between, so a user who wants to hand one profile's names to another wallet hands over one key for each name. -2. **The phrase alone does not restore a device.** Until the scan lands, an imported phrase gives a device its keys and no way to find what they own. -3. **The scan's shape is unsettled.** Going from an address to the names it owns is not something a registry answers on chain, so it needs the registrar or an indexer, and which one decides who sees the scan. How long a run of empty accounts ends the walk is a guess until it is written, and a user who knows better has to be able to send it further. -4. **The same phrase on two devices collides.** The counter lives in one database, so both take the same account and each believes it is free. Sharing it needs a backup both can read. -5. **A profile hidden after it was bound keeps its accounts.** The check is at bind time only. -6. **Account indexes are not dense.** An account can be taken and never used, and a run of empty accounts is how the scan stops, so one far above a gap can be missed. -7. **Gas and discovery pull against each other.** If an account ever pays for anything, whatever funds it links accounts on chain. If it never pays, no wallet finds it past account 0. -8. **Nothing records which layout a seed was used under.** A phrase used in another wallet may hold accounts at paths this doc does not describe. -9. **A restored database hands out an account that is already used.** Its counter is behind what the master has reached, and nothing detects that, so a name can be bought with an account that already owns one until the scan resets the counter. -10. **Purpose `5564'` is ours.** Reserved for stealth keys, taken from an [ERC-5564](https://eips.ethereum.org/EIPS/eip-5564) number rather than registered as a BIP-43 purpose, and nothing here derives at it. +1. **There is no per-profile transfer.** The master phrase derives every account on the device and an account secret controls one account, with nothing in between, so a user who wants to move one profile's names to another wallet has to export one key for each name. +2. **The phrase alone does not restore a device.** Until the scan is implemented, an imported phrase gives a device its keys and no way to find what they own. +3. **The scan's design is not decided.** Finding the names an address owns is not a query a registry answers on chain, so it requires the registrar or an indexer, and that choice determines which party observes the scan. The length of the run of empty accounts that ends the scan is not yet decided, and the user must be able to extend the scan. +4. **The same phrase on two devices collides.** The counter is stored in one database, so both devices bind the same account and each treats it as free. Sharing the counter requires a backup both devices can read. +5. **A profile hidden after an account was bound to it keeps its accounts.** The check runs at bind time only. +6. **Account indexes are not dense.** An account can be bound and never used, and a run of empty accounts ends the scan, so an account after a gap can be missed. +7. **Gas payments and discovery conflict.** If an account ever pays for anything, whatever funds it links accounts on chain. If it never pays, no wallet discovers it past account 0. +8. **Nothing records which layout a seed was used with.** A phrase used in another wallet may own accounts at paths this doc does not describe. +9. **A restored database binds an account that is already in use.** Its counter is below the highest index used, and nothing detects that, so a name can be bought with an account that already owns one until the scan resets the counter. -## Files +## Main files - `src/Simplex/Chat/Wallet.hs`, derivation. - `src/Simplex/Chat/Store/Wallets.hs`, the two tables. -- `src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs` and the Postgres twin. +- `src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs` and the corresponding Postgres migration. - `tests/WalletTests.hs`. -- `tests/SchemaDump.hs` and `tests/PostgresSchemaDump.hs`, which selected what to test by taking every migration after the last one without a reverse step, and now take every migration from the first one that has a reverse step, applying any that has none. -- Derivation uses the `BIP32`, `BIP39` and `Eth.Address` modules from simplexmq (simplex-chat/simplexmq#1843) and adds no dependency to this package. +- `tests/SchemaDump.hs` and `tests/PostgresSchemaDump.hs`, which selected the migrations to test as every migration after the last one without a down migration, and now select every migration from the first one that has a down migration, applying any that has none. +- Derivation uses the `BIP32`, `BIP39`, `Secp256k1` and `Eth.Address` modules from simplexmq (simplex-chat/simplexmq#1843) and adds no dependency to this package. ## What is verified **File:** `tests/WalletTests.hs`. Each of these is a test, not a claim. -1. **Vectors.** The two addresses above reproduce from `abandon ... about`, as does account 0's secret, pinned to the value another wallet shows for it. A 24 word phrase imported through the command reaches a pinned address end to end, so a change of path fails here rather than shipping, and the account a command names is the account whose key comes back. -2. **Isolation.** Ten accounts' addresses are all different, and an account path hardens its account component. -3. **Refusals.** A second generate; a phrase that is not 24 valid words; `bind`, `delete` and `export master` on a device with no wallet; `bind` on a hidden profile, on an account another profile holds, and on an imported master whose counter is unknown; `export account` for an account the profile does not hold; every index at or above 2^31, on `address`, `bind` and `export account` alike. -4. **Binding and reads.** A profile binds several accounts, each `bind` returns the account it bound, and the profile exports its own, an account bound by index moves the counter past it so the next one does not collide and never moves it back, `bind account=` attaches a scanned one, an account a deleted profile leaves behind is taken by another profile, and `address` returns the counter twice running without moving it and derives for an account with no row. -5. **Encoding and persistence.** An account secret whose first byte is zero keeps its 64 hex digits; the wallet, its accounts, the counter and the phrase survive a restart; and deleting the wallet takes its accounts and starts the counter over. +1. **Vectors.** The two addresses above are derived from `abandon ... about`, as is account 0's secret, pinned to the value another wallet shows for it. A 24 word phrase imported through the command derives a pinned address end to end, so a change of path fails here rather than in a release, and the account a command names is the account whose key is returned. +2. **Isolation.** Ten accounts' addresses are all different, and the paths of accounts 0 and 7 have a hardened account component. +3. **Rejections.** A second generate; a phrase that is not 24 valid words; `bind`, `delete` and `export master` on a device with no wallet; `bind` on a hidden profile, on an account another profile holds, and on an imported master whose counter is unknown; `export account` for an account the profile does not hold; index 2^31, on `address`, `bind` and `export account` alike; an index of 2^32 or more is rejected as a bad command. +4. **Binding and reads.** A profile binds several accounts, each `bind` returns the account it bound, and the profile exports its own; an account bound by index moves the counter past it so the next one does not collide, and never moves it back; `bind account=` attaches a scanned one; an account left unbound by deleting its profile is bound to another profile; and two consecutive `address` calls return the same address without changing the counter, and derive an address for an account with no row. +5. **Encoding and persistence.** An account secret whose first byte is zero is rendered with 64 hex digits; the wallet, its accounts, the counter and the phrase persist across a restart; and deleting the wallet deletes its accounts and resets the counter. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 3e6b1fd6ec..da0561a19c 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."714803f7a00f51379621b131ffa561d7b61c26d2" = "1ki45ifixfgdkyg0xrgirr9w8821aszfz304l3fb4fbv0aq4dk83"; + "https://github.com/simplex-chat/simplexmq.git"."256ef554486e5de67401c3d9d0e5ce2c35402247" = "1nw1y8apy8s63i2w9vapzyyhknxppq11c6hamsnz5agccp3mvhir"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 8214b686d2..396783d64c 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -65,7 +65,6 @@ import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeIss import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Badges.Service (BadgeBalance (..), BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), BadgeStatement (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..), currentBadgeServiceVersion) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) -import Simplex.Chat.Store.Wallets (WalletSeed (..), accountHeldBy, bindAccount, createWalletSeed, deleteWalletSeed, getUserAccounts, getWalletSeed, resolveAccount) import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress, WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, newSeedEntropy, seedMnemonic) import Simplex.Chat.Call import Simplex.Chat.Controller @@ -97,6 +96,7 @@ import Simplex.Chat.Store.Messages import Simplex.Chat.Store.NoteFolders import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared +import Simplex.Chat.Store.Wallets (WalletSeed (..), accountHeldBy, bindAccount, createWalletSeed, deleteWalletSeed, getUserAccounts, getWalletSeed, resolveAccount) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -1507,7 +1507,7 @@ processChatCommand cxt nm = \case -- the counter starts at 0 for a generated seed and is unknown for an imported one (entropy, nextAccount) <- case mnemonic_ of Nothing -> (,Just 0) <$> (asks random >>= atomically . newSeedEntropy) - Just phrase -> (,Nothing) <$> liftWallet (entropyFromMnemonic $ encodeUtf8 phrase) + Just phrase -> (,Nothing) <$> liftWallet (entropyFromMnemonic phrase) created <- withFastStore' $ \db -> createWalletSeed db entropy nextAccount unless created $ throwWalletError WEMasterExists pure $ CRWallet user (Just []) @@ -6029,13 +6029,13 @@ throwWalletError :: WalletError -> CM a throwWalletError = throwChatError . CEWallet liftWallet :: Either WalletError a -> CM a -liftWallet = either throwWalletError pure +liftWallet = liftEitherWith (ChatError . CEWallet) withWalletStore :: (DB.Connection -> IO (Either WalletError a)) -> CM a withWalletStore action = liftWallet =<< withFastStore' action seedAccount :: WalletSeed -> AccountIndex -> CM (AccountKey, WalletAddress) -seedAccount WalletSeed {wsEntropy} n = liftWallet =<< liftIO (deriveAccount wsEntropy n) +seedAccount WalletSeed {wsEntropy} n = liftError' (ChatError . CEWallet) (deriveAccount wsEntropy n) chatCommandP :: Parser ChatCommand chatCommandP = diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs index 68fff6d313..e32d0dc2f5 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs @@ -28,4 +28,4 @@ CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_a CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id); |] --- no reverse step, see the SQLite migration +-- no down migration, see the SQLite migration diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs index 4d998fe751..dec9eb8cfb 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs @@ -27,4 +27,4 @@ CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_a CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id); |] --- No reverse step: it would drop the only copy of the master entropy. +-- No down migration: it would delete the master entropy, which may have no other copy. diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index c04877014a..fa121c4719 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -4416,6 +4416,14 @@ Query: Plan: SEARCH groups USING INTEGER PRIMARY KEY (rowid=?) +Query: + UPDATE wallet_accounts SET user_id = ? + WHERE wallet_seed_id = ? AND account_index = ? AND user_id IS NULL + RETURNING wallet_account_id + +Plan: +SEARCH wallet_accounts USING INDEX idx_wallet_accounts_wallet_seed_id_account_index (wallet_seed_id=? AND account_index=?) + Query: WITH user_groups AS MATERIALIZED ( SELECT g.group_id @@ -5723,13 +5731,6 @@ Query: Plan: SEARCH users USING INTEGER PRIMARY KEY (rowid=?) -Query: - UPDATE wallet_accounts SET user_id = ? - WHERE wallet_seed_id = ? AND account_index = ? AND user_id IS NULL - -Plan: -SEARCH wallet_accounts USING INDEX idx_wallet_accounts_wallet_seed_id_account_index (wallet_seed_id=? AND account_index=?) - Query: UPDATE wallet_seeds SET next_account_index = ? WHERE wallet_seed_id = ? AND next_account_index IS NOT NULL AND next_account_index <= ? diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index 677d2057bd..7fdc5e5548 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -91,7 +91,7 @@ getUserAccounts db sId userId = |] (sId, userId) --- | Which profile holds an account: 'Nothing' when it is unknown, @Just Nothing@ when no profile holds it. +-- | Which profile holds an account: 'Nothing' when there is no row for it, @Just Nothing@ when no profile holds it. accountUser :: DB.Connection -> SeedId -> AccountIndex -> IO (Maybe (Maybe UserId)) accountUser db sId n = maybeFirstRow fromOnly $ @@ -100,24 +100,25 @@ accountUser db sId n = bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> IO (Either WalletError (WalletSeed, AccountIndex)) bindAccount db userId accountIdx_ = runExceptT $ do r@(WalletSeed {wsId}, n) <- ExceptT $ resolveAccount db accountIdx_ - taken <- liftIO $ accountUser db wsId n >>= \case + held <- liftIO $ accountUser db wsId n >>= \case Just (Just heldBy) -> pure $ heldBy == userId - -- the update sets user_id only while it is NULL, so the read after it shows which profile holds the account - Just Nothing -> setAccountUser db wsId userId n >> accountHeldBy db wsId userId n + Just Nothing -> setAccountUser db wsId userId n Nothing -> True <$ insertAccount db wsId userId n - unless taken $ throwError WEAccountBound + unless held $ throwError WEAccountBound liftIO $ raiseNextAccount db wsId n pure r -setAccountUser :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO () +setAccountUser :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bool setAccountUser db sId userId n = - DB.execute - db - [sql| - UPDATE wallet_accounts SET user_id = ? - WHERE wallet_seed_id = ? AND account_index = ? AND user_id IS NULL - |] - (userId, sId, n) + fmap isJust . maybeFirstRow (fromOnly @Int64) $ + DB.query + db + [sql| + UPDATE wallet_accounts SET user_id = ? + WHERE wallet_seed_id = ? AND account_index = ? AND user_id IS NULL + RETURNING wallet_account_id + |] + (userId, sId, n) accountHeldBy :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bool accountHeldBy db sId userId n = (== Just (Just userId)) <$> accountUser db sId n diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5433c74958..108ad05ba3 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1119,10 +1119,10 @@ walletErrorText = \case WENoMaster -> "this device has no wallet" WEMasterExists -> "this device already has a wallet" WEBadMnemonic -> "not a valid 24 word recovery phrase" - WEHiddenProfile -> "a hidden profile cannot own an account" + WEHiddenProfile -> "a hidden profile cannot hold an account" WEAccountBound -> "another profile holds this account" WEAccountNotHeld -> "this profile does not hold this account" - WECounterUnknown -> "the next account is unknown after an import, scan the chain first" + WECounterUnknown -> "the next account is unknown after an import" WEIndexTooLarge -> "account index is too large to harden" WEDerivation e -> "derivation failed: " <> T.pack e diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index d6a76ac496..16e7218891 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -23,7 +23,6 @@ import qualified Data.Aeson.TH as JQ import Data.Bifunctor (bimap, first) import qualified Data.ByteArray as BA import qualified Data.ByteArray.Encoding as BAE -import Data.ByteString (ByteString) import Data.Text (Text) import Data.Text.Encoding (decodeLatin1) import Data.Word (Word32) @@ -69,7 +68,7 @@ masterStrength = B39.MS256 newSeedEntropy :: TVar ChaChaDRG -> STM BA.ScrubbedBytes newSeedEntropy g = B39.mnemonicToEntropy <$> B39.randomMnemonic masterStrength g -entropyFromMnemonic :: ByteString -> Either WalletError BA.ScrubbedBytes +entropyFromMnemonic :: Text -> Either WalletError BA.ScrubbedBytes entropyFromMnemonic phrase = case B39.parseMnemonic phrase of Right m | length (B39.mnemonicWords m) == B39.strengthWordCount masterStrength -> Right $ B39.mnemonicToEntropy m diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 5e1fdc94bb..4386f507eb 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -30,7 +30,7 @@ testPhrase24 :: ByteString testPhrase24 = B.unwords $ replicate 23 "abandon" <> ["art"] seedEntropy :: ByteString -> BA.ScrubbedBytes -seedEntropy phrase = B39.mnemonicToEntropy . either error id $ B39.parseMnemonic phrase +seedEntropy phrase = B39.mnemonicToEntropy . either error id $ B39.parseMnemonic (safeDecodeUtf8 phrase) walletAccount :: BA.ScrubbedBytes -> AccountIndex -> IO (AccountKey, WalletAddress) walletAccount entropy n = either (error . show) id <$> deriveAccount entropy n @@ -50,7 +50,7 @@ accountBound cc idx = (take 1 . words <$> getTermLine cc) `shouldReturn` [idx] walletDerivationTests :: Spec walletDerivationTests = do - Hspec.it "accounts are the accounts another wallet derives for the same phrase" $ do + Hspec.it "derives the addresses another wallet derives for the same phrase" $ do let addrOf n = address . snd <$> walletAccount (seedEntropy testPhrase12) n addrOf 0 `shouldReturn` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" addrOf 1 `shouldReturn` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265" @@ -60,7 +60,7 @@ walletDerivationTests = do Hspec.it "every account has its own address" $ do addrs <- mapM (fmap (address . snd) . walletAccount (seedEntropy testPhrase12)) [0 .. 9] length (nub addrs) `shouldBe` 10 - Hspec.it "a secret whose first byte is zero keeps its 64 hex digits" $ do + Hspec.it "renders a secret whose first byte is zero with 64 hex digits" $ do k <- either error id <$> S.mkPrivateKey (BA.convert $ B.pack ('\0' : replicate 31 '\1')) let secret = T.unpack $ accountSecret k take 4 secret `shouldBe` "0x00" @@ -68,30 +68,30 @@ walletDerivationTests = do Hspec.it "renders the path an account is derived at" $ do (keyPath . snd <$> walletAccount (seedEntropy testPhrase12) 0) `shouldReturn` "m/44'/60'/0'/0/0" (keyPath . snd <$> walletAccount (seedEntropy testPhrase12) 7) `shouldReturn` "m/44'/60'/7'/0/0" - Hspec.it "refuses an index BIP-32 cannot harden" $ + Hspec.it "rejects an account index at or above 2^31" $ (void <$> deriveAccount (seedEntropy testPhrase12) 2147483648) `shouldReturn` Left WEIndexTooLarge Hspec.it "round-trips the phrase it was imported from" $ seedMnemonic (seedEntropy testPhrase24) `shouldBe` Right (safeDecodeUtf8 testPhrase24) - Hspec.it "takes 24 words only, with a valid checksum" $ do - entropyFromMnemonic testPhrase24 `shouldSatisfy` isRight - entropyFromMnemonic testPhrase12 `shouldBe` Left WEBadMnemonic - entropyFromMnemonic (B.unwords $ replicate 24 "abandon") `shouldBe` Left WEBadMnemonic + Hspec.it "accepts only 24 words with a valid checksum" $ do + entropyFromMnemonic (safeDecodeUtf8 testPhrase24) `shouldSatisfy` isRight + entropyFromMnemonic (safeDecodeUtf8 testPhrase12) `shouldBe` Left WEBadMnemonic + entropyFromMnemonic (T.unwords $ replicate 24 "abandon") `shouldBe` Left WEBadMnemonic walletTests :: SpecWith TestParams walletTests = do - it "creates no wallet until asked, and only one" testWalletCreate - it "binds the next free account, and re-binding one it holds changes nothing" testWalletBind + it "creates a wallet only on the create command, and at most one" testWalletCreate + it "binds the next free account, and re-binding an account the profile holds changes nothing" testWalletBind it "keeps each profile's accounts apart" testWalletAccountsPerProfile - it "taking the next account skips one already bound by index" testWalletBindByIndexThenNext - it "leaves a deleted profile's account for another profile to take" testWalletDeletedProfileAccount - it "derives an address without taking it" testWalletAddress + it "binding the next account skips one already bound by index" testWalletBindByIndexThenNext + it "a deleted profile's account can be bound to another profile" testWalletDeletedProfileAccount + it "derives an address without binding it" testWalletAddress it "exports the master phrase and one account's secret" testWalletExport - it "will not take a new account on an imported phrase" testWalletImport - it "the wallet, the accounts and the counter come back after a restart" testWalletPersists - it "deletes the wallet, and one can be made again" testWalletDelete + it "does not bind the next account after an import" testWalletImport + it "the wallet, the accounts and the counter persist across a restart" testWalletPersists + it "deletes the wallet, and a new one can be created" testWalletDelete it "does not bind an account to a hidden profile" testWalletHiddenProfile it "exports only an account the profile holds" testWalletExportNotHeld - it "refuses an index BIP-32 cannot harden, on every command" testWalletIndexTooLarge + it "rejects an account index at or above 2^31 on every command" testWalletIndexTooLarge testWalletCreate :: HasCallStack => TestParams -> IO () testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -224,13 +224,13 @@ testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase24) alice <## "wallet, no accounts for this profile" alice ##> "/_wallet bind 1" - alice <## "wallet: the next account is unknown after an import, scan the chain first" + alice <## "wallet: the next account is unknown after an import" alice ##> "/_wallet address" - alice <## "wallet: the next account is unknown after an import, scan the chain first" + alice <## "wallet: the next account is unknown after an import" alice ##> "/_wallet bind 1 account=4" alice `accountBound` "4" alice ##> "/_wallet bind 1" - alice <## "wallet: the next account is unknown after an import, scan the chain first" + alice <## "wallet: the next account is unknown after an import" testWalletPersists :: HasCallStack => TestParams -> IO () testWalletPersists ps = do @@ -275,7 +275,7 @@ testWalletHiddenProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> alice <## "messages are hidden (use /tail to view)" alice <## "profile is hidden" alice ##> "/_wallet bind 2" - alice <## "wallet: a hidden profile cannot own an account" + alice <## "wallet: a hidden profile cannot hold an account" testWalletExportNotHeld :: HasCallStack => TestParams -> IO () testWalletExportNotHeld ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do From 0c0a9ec57b9b1dde1f3ba204be164adfa322c7b0 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 24 Sep 2026 16:39:17 +0200 Subject: [PATCH 54/67] self-review iteration 5 --- cabal.project | 2 +- docs/rfcs/2026-09-10-wallet-keys.md | 20 ++++++------- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Library/Commands.hs | 2 +- src/Simplex/Chat/Store/Wallets.hs | 12 ++++---- src/Simplex/Chat/View.hs | 25 ++++++++-------- src/Simplex/Chat/Wallet.hs | 9 +++--- tests/WalletTests.hs | 45 +++++++++++++++------------- 8 files changed, 60 insertions(+), 57 deletions(-) diff --git a/cabal.project b/cabal.project index 95fbeeeb88..b9423c022f 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 256ef554486e5de67401c3d9d0e5ce2c35402247 + tag: 4719c432589c7b2f964d10d0502ee77701ef78fe source-repository-package type: git diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 0e55941c8e..9e9a48927d 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -4,7 +4,7 @@ The client has no static keys of its own for blockchain use cases. It requires keys at addresses it can derive again after a restart, after a database restore, or on a new device; otherwise whatever those addresses hold is lost with the device. -The first consumer is [public namespaces](./2026-05-21-public-namespaces.md): a name is a record on a public blockchain that contains the owner's address, and every name is owned by an account of its own, so one chat profile requires many accounts. The wallet is not specific to names. An account is an ordinary Ethereum account, and [stealth transfers](https://github.com/simplex-chat/simplex-chat/pull/7519) will use accounts in the same way. A device also contains several chat profiles, which are meant to stay isolated, and the one phrase the user writes down has to recover all of them. +The first consumer is [public namespaces](./2026-05-21-public-namespaces.md): a name is a record on a public blockchain that contains the owner's address, and every name is owned by an account of its own, so one chat profile requires many accounts. The wallet is not specific to names. An account is an ordinary Ethereum account, and [stealth transfers](https://github.com/simplex-chat/simplex-chat/pull/7519) will use accounts in the same way. A device also contains several chat profiles, which are meant to stay isolated, and the one phrase the user writes down has to recover the keys of all of them. Out of scope of this doc: buying a name, the names protocol, the registrar, signing, the chain scan and the command that records its results, importing an account key that the master does not derive, more than one master, and stealth keys. Buying is not implemented yet either, so key derivation is merged first and can be reviewed on its own. @@ -58,7 +58,7 @@ An account is bound to at most one chat profile, and a profile to any number of Accounts are allocated in order and never reused, because an account the device no longer tracks still owns whatever it holds, and an account can remain unbound. Nothing is bound when a profile is created; an account is bound on first use, when the user buys a name for a profile, so a device on which the user never buys anything has never derived a key. -No account is bound to a hidden profile, so a hidden profile cannot own a name. Two things would leak: the master derives every account, so whoever can use any profile can also derive a hidden profile's account keys; and a name is written into the profile's own database row and listed across the device, while a hidden profile is a filter on what is shown, not encryption. Closing either requires changes in the profiles and in the name record, not in the key layout. Incognito is a property of a connection in this app rather than of a profile, so there is nothing at this level to reject; an incognito connection has no profile of its own to bind an account to. +An account cannot be bound to a hidden profile, so no name can be bought for a hidden profile. Two things would leak: the master derives every account, so whoever can use any profile can also derive a hidden profile's account keys; and a name is written into the profile's own database row and listed across the device, while a hidden profile is a filter on what is shown, not encryption. Closing either requires changes in the profiles and in the name record, not in the key layout. Incognito is a property of a connection in this app rather than of a profile, so there is nothing at this level to reject; an incognito connection has no profile of its own to bind an account to. ## Commands @@ -83,13 +83,13 @@ A command that acts on a profile's accounts names the profile and is rejected wh `create` always names its source, so no key material is generated by typing a prefix. An imported phrase must be 24 BIP-39 English words with a valid checksum, normalised for case and spacing. There is no BIP-39 passphrase, because it would be a second secret to back up and losing it would look exactly like losing the phrase, so a phrase used with a passphrase elsewhere derives different keys here. -`bind` without an argument binds the account at a counter on the master, which is a high-water mark and not a count of bound accounts, and returns the bound account's index, path and address. With `account=` it binds that account, which is how an account found by a scan is attached to the profile it belongs to, and it is rejected for an account another profile holds. After an import the counter is unknown rather than zero, because the phrase does not encode how many accounts it has been used for, so binding the next account is rejected until a scan sets the counter, while binding a known account is still allowed. +`bind` without `account=` binds the account at a counter on the master, which is a high-water mark and not a count of bound accounts, and returns the bound account's index, path and address. With `account=` it binds that account, which is how an account found by a scan is attached to the profile it belongs to, and it is rejected for an account another profile holds. After an import the counter is unknown rather than zero, because the phrase does not encode how many accounts it has been used for, so binding the next account is rejected until a scan sets the counter, while binding a known account is still allowed. BIP-32 marks an index as hardened by setting its top bit, so an index at or above 2^31 already has that bit set and derives the same key as the index 2^31 below it: account 2^31 is account 0. That is a collision, not a loss of hardening, and it would put one key under two account indexes. Every index at or above 2^31 is rejected, including one read from the counter; the simplexmq function that builds the path rejects it too, and the columns have CHECK constraints for that bound, so later writes cannot exceed it. The counter's bound is one higher than an account's, because it contains the next index to bind, and 2^31 there means the counter has passed every index that can be hardened. `address` reads the counter without changing it, so two calls return the same address, and it derives an address for an account the database has no row for, which a device that lost its database requires. One address per call is sufficient: a caller that scans the tree calls it in a loop. -`export account` is rejected unless the profile holds the account, so every exported key belongs to an account that is already bound, which `bind` without an index never returns: the counter is either past it or unknown. An export is a copy and not a transfer: the device still derives what it exported and can still sign with it, so giving an account key away leaves two parties able to act as its owner until whatever it holds is transferred on chain. Signing is not in this change, and when it is added it is a command here that signs and returns a signature, not `export account` followed by signing elsewhere, which would make the narrow export the ordinary path. `delete` leaves accounts registered to their addresses, recoverable only from the phrase. +`export account` is rejected unless the profile holds the account, so every exported key belongs to an account that is already bound, which `bind` without an index never returns: the counter is either past it or unknown. An export is a copy and not a transfer: the device still derives what it exported and can still sign with it, so giving an account key away leaves two parties able to act as its owner until whatever it holds is transferred on chain. Signing is not in this change, and when it is added it is a command here that signs and returns a signature, not `export account` followed by signing elsewhere, which would make the narrow export the ordinary path. `delete` leaves whatever the accounts own on chain, recoverable only from the phrase. ```haskell data WalletAddress = WalletAddress {accountIndex :: Word32, keyPath :: Text, address :: Text} @@ -114,7 +114,7 @@ Three cases, by how much of the database is restored. What the scan finds is unbound, and the user attaches each account to a profile with `bind account=`. The names those accounts own identify them, which is what makes the choice possible at all: the user chooses between names they recognise, not between numbers. Binding changes no key and signs nothing, because ownership does not change, only the profile under which the app shows the account. Pointing a name at that profile's address is a separate signed edit of the name's record. -**The database is restored from a backup.** It contains the accounts as of the backup and nothing written after it, so its counter is behind. A counter that is behind is worse than one that is unknown, because it appears valid, so `bind` returns an account that is already in use. Nothing here distinguishes a restored database from a current one, so clearing the counter is the responsibility of whatever restores a database, along with the scan that sets it again. +**The database is restored from a backup.** It contains the accounts as of the backup and nothing written after it, so its counter is behind if an account was bound after the backup. A counter that is behind is worse than one that is unknown, because it appears valid, so `bind` returns an account that is already in use. Nothing here distinguishes a restored database from a current one, so clearing the counter is the responsibility of whatever restores a database, along with the scan that sets it again. **The database is current.** It records which profile holds which account, so the user is asked nothing. @@ -144,7 +144,7 @@ Only entropy that nothing can derive is stored: the master, always 32 bytes, sin `users` is not changed: the mapping is stored in the account row, and the index on `user_id` is not unique, because a profile owns as many accounts as it owns names. One seed per device is enforced by `single_seed` and the unique index on it, which a later change removes with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop an inline constraint without rebuilding the table. Deleting the master deletes its account rows, because an account index without its entropy derives nothing. The migration has no down migration, because a down migration would delete the master entropy, which may have no other copy. A down migration runs when an older app opens a newer database and the user confirms "Downgrade and open chat", and the backup made then is overwritten by the next upgrade. Without a down migration the older app reports that the database is newer than the app, and changes nothing. -A null `account_index` marks an account whose key was imported rather than derived, which the master phrase does not recover and the schema must not suggest it does. Importing one is not implemented here; the column is nullable now so that a row written later is read correctly. That feature also requires storage for the imported secret and an optional link to a seed, because such an account belongs to no seed and must not be deleted with one; on SQLite, making `wallet_seed_id` nullable rebuilds the table. +A null `account_index` marks an account whose key was imported rather than derived. The master phrase does not recover such an account, and the schema must not suggest that it does. Importing one is not implemented here; the column is nullable now so that a row written later is read correctly. That feature also requires storage for the imported secret and an optional link to a seed, because such an account belongs to no seed and must not be deleted with one; on SQLite, making `wallet_seed_id` nullable rebuilds the table. ## Threat model @@ -152,7 +152,7 @@ A null `account_index` marks an account whose key was imported rather than deriv - **Someone with one account key.** Can act as that account's owner permanently, because an export is a copy and the device keeps deriving the same key. Cannot derive another account's key. - **A wallet the master phrase is imported into.** Enumerating BIP-44 accounts computes account extended public keys, and some wallets send them to a vendor, which gives that vendor every account on the device at once, across every profile. That is what an account for each name otherwise prevents. - **Whoever answers the recovery scan.** Receives every address the scan derives from the phrase, in one sequence of requests, so it can link every account on the device, across profiles, and recognise addresses that own nothing yet, which is where future accounts will be. `address` derives an address for any index directly from the master, so a caller can enumerate hidden profiles' addresses too. This is the largest privacy cost of the design. -- **A paired device.** Wallet commands are allowed from a paired device like other chat commands: `export master` returns the whole wallet, `create` on a device that has no seed creates a seed chosen by the paired device, and `delete` deletes the master entropy, which may have no other copy. +- **A paired device.** Wallet commands are allowed from a paired device like other chat commands: `export master` returns the whole wallet, `create mnemonic=` on a device that has no seed imports a phrase that the paired device sends, and `delete` deletes the master entropy, which may have no other copy. - **Someone reading the logs.** The core logs no command and no response, so a phrase passed to `create` is not written to any log. - **A page open in the user's browser.** The websocket server in `apps/simplex-chat/Server.hs`, which runs only with `--chat-server-port` and listens on 127.0.0.1, accepts any local connection, requires no token and checks no `Origin`, and websockets are not bound by the same origin policy, so any page loaded while that server runs can send `export master` and read the response. It also prints every command it receives, that phrase included. Both are properties of that server, which this change makes a more valuable target, and closing them requires changes to that server rather than to the wallet. @@ -165,8 +165,8 @@ A null `account_index` marks an account whose key was imported rather than deriv 5. **A profile hidden after an account was bound to it keeps its accounts.** The check runs at bind time only. 6. **Account indexes are not dense.** An account can be bound and never used, and a run of empty accounts ends the scan, so an account after a gap can be missed. 7. **Gas payments and discovery conflict.** If an account ever pays for anything, whatever funds it links accounts on chain. If it never pays, no wallet discovers it past account 0. -8. **Nothing records which layout a seed was used with.** A phrase used in another wallet may own accounts at paths this doc does not describe. -9. **A restored database binds an account that is already in use.** Its counter is below the highest index used, and nothing detects that, so a name can be bought with an account that already owns one until the scan resets the counter. +8. **Nothing records which layout a seed was used with.** Another wallet may have derived accounts from the phrase at paths this doc does not describe. +9. **`bind` on a restored database can return an account that is already in use.** Its counter can be below the highest index used, and nothing detects that, so a name can be bought with an account that already owns one until the scan resets the counter. ## Main files @@ -184,5 +184,5 @@ A null `account_index` marks an account whose key was imported rather than deriv 1. **Vectors.** The two addresses above are derived from `abandon ... about`, as is account 0's secret, pinned to the value another wallet shows for it. A 24 word phrase imported through the command derives a pinned address end to end, so a change of path fails here rather than in a release, and the account a command names is the account whose key is returned. 2. **Isolation.** Ten accounts' addresses are all different, and the paths of accounts 0 and 7 have a hardened account component. 3. **Rejections.** A second generate; a phrase that is not 24 valid words; `bind`, `delete` and `export master` on a device with no wallet; `bind` on a hidden profile, on an account another profile holds, and on an imported master whose counter is unknown; `export account` for an account the profile does not hold; index 2^31, on `address`, `bind` and `export account` alike; an index of 2^32 or more is rejected as a bad command. -4. **Binding and reads.** A profile binds several accounts, each `bind` returns the account it bound, and the profile exports its own; an account bound by index moves the counter past it so the next one does not collide, and never moves it back; `bind account=` attaches a scanned one; an account left unbound by deleting its profile is bound to another profile; and two consecutive `address` calls return the same address without changing the counter, and derive an address for an account with no row. +4. **Binding and reads.** Several accounts are bound to one profile, each `bind` returns the account it bound, and that profile's accounts can be exported; binding an account by index moves the counter past it so the next one does not collide, and never moves it back; `bind account=` binds an account after an import; an account left unbound by deleting its profile is bound to another profile; and two consecutive `address` calls return the same address without changing the counter, and derive an address for an account with no row. 5. **Encoding and persistence.** An account secret whose first byte is zero is rendered with 64 hex digits; the wallet, its accounts, the counter and the phrase persist across a restart; and deleting the wallet deletes its accounts and resets the counter. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index da0561a19c..2defe00188 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."256ef554486e5de67401c3d9d0e5ce2c35402247" = "1nw1y8apy8s63i2w9vapzyyhknxppq11c6hamsnz5agccp3mvhir"; + "https://github.com/simplex-chat/simplexmq.git"."4719c432589c7b2f964d10d0502ee77701ef78fe" = "014iy7a0jqxhky62140smhj1jsr24d1xlr94hcv1fpngw84bn0s4"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 396783d64c..f7ac66e26f 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1527,7 +1527,7 @@ processChatCommand cxt nm = \case unless held $ throwWalletError WEAccountNotHeld (k, a) <- seedAccount seed n pure $ CRWalletAccountSecret user a (accountSecret k) - APIDeleteWallet -> withUser $ \_ -> do + APIDeleteWallet -> withUser_ $ do deleted <- withFastStore' deleteWalletSeed unless deleted $ throwWalletError WENoMaster ok_ diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index 7fdc5e5548..6b4d2fb776 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -3,7 +3,6 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} -{-# LANGUAGE TypeApplications #-} module Simplex.Chat.Store.Wallets ( SeedId, @@ -25,7 +24,6 @@ import Control.Monad.IO.Class (liftIO) import qualified Data.ByteArray as BA import Data.ByteString (ByteString) import Data.Int (Int64) -import Data.Maybe (isJust) import Simplex.Chat.Wallet (AccountIndex, WalletError (..), checkAccountIndex) import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) @@ -57,7 +55,7 @@ getWalletSeed db = createWalletSeed :: DB.Connection -> BA.ScrubbedBytes -> Maybe AccountIndex -> IO Bool createWalletSeed db entropy nextAccount = - fmap isJust . maybeFirstRow (fromOnly @SeedId) $ + rowReturned $ DB.query db [sql| @@ -69,8 +67,7 @@ createWalletSeed db entropy nextAccount = deleteWalletSeed :: DB.Connection -> IO Bool deleteWalletSeed db = - fmap isJust . maybeFirstRow (fromOnly @SeedId) $ - DB.query_ db "DELETE FROM wallet_seeds RETURNING wallet_seed_id" + rowReturned $ DB.query_ db "DELETE FROM wallet_seeds RETURNING wallet_seed_id" resolveAccount :: DB.Connection -> Maybe AccountIndex -> IO (Either WalletError (WalletSeed, AccountIndex)) resolveAccount db accountIdx_ = runExceptT $ do @@ -110,7 +107,7 @@ bindAccount db userId accountIdx_ = runExceptT $ do setAccountUser :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bool setAccountUser db sId userId n = - fmap isJust . maybeFirstRow (fromOnly @Int64) $ + rowReturned $ DB.query db [sql| @@ -136,3 +133,6 @@ raiseNextAccount db sId 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, n, userId) + +rowReturned :: IO [Only Int64] -> IO Bool +rowReturned = fmap (not . null) diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 108ad05ba3..0882d700b2 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -1114,18 +1114,6 @@ 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 hold an account" - WEAccountBound -> "another profile holds this account" - WEAccountNotHeld -> "this profile does not hold this account" - WECounterUnknown -> "the next account is unknown after an import" - WEIndexTooLarge -> "account index is too large to harden" - WEDerivation e -> "derivation failed: " <> T.pack e - viewContactsList :: [Contact] -> [StyledString] viewContactsList = let getLDN :: Contact -> ContactName @@ -2823,7 +2811,18 @@ 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] + CEWallet walletErr -> + let reason = case walletErr of + 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 hold an account" + WEAccountBound -> "another profile holds this account" + WEAccountNotHeld -> "this profile does not hold this account" + WECounterUnknown -> "the next account is unknown after an import" + WEIndexTooLarge -> "account index must be below 2^31" + WEDerivation e -> "derivation failed: " <> T.pack e + in [plain $ "wallet: " <> reason] 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 16e7218891..c79a63c39d 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -20,7 +20,7 @@ import Control.Monad.Except import Control.Monad.IO.Class (liftIO) import Crypto.Random (ChaChaDRG) import qualified Data.Aeson.TH as JQ -import Data.Bifunctor (bimap, first) +import Data.Bifunctor (bimap) import qualified Data.ByteArray as BA import qualified Data.ByteArray.Encoding as BAE import Data.Text (Text) @@ -32,6 +32,7 @@ import qualified Simplex.Messaging.Crypto.Secp256k1 as S import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Eth.Address (addressFromPrivateKey, ethereumPath) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) +import Simplex.Messaging.Util (liftEitherWith, liftError') type AccountIndex = Word32 @@ -80,9 +81,9 @@ seedMnemonic = bimap WEDerivation (decodeLatin1 . B39.mnemonicPhrase) . B39.entr deriveAccount :: BA.ScrubbedBytes -> AccountIndex -> IO (Either WalletError (AccountKey, WalletAddress)) deriveAccount entropy n = runExceptT $ do path <- liftEither $ accountPath n - m <- liftEither . first WEDerivation $ B39.entropyToMnemonic entropy - master <- ExceptT $ first WEDerivation <$> B32.masterKey (B39.mnemonicToSeed m "") - k <- ExceptT $ fmap B32.xkKey . first WEDerivation <$> B32.derivePath master path + m <- liftEitherWith WEDerivation $ B39.entropyToMnemonic entropy + master <- liftError' WEDerivation $ B32.masterKey (B39.mnemonicToSeed m "") + k <- B32.xkKey <$> liftError' WEDerivation (B32.derivePath master path) a <- liftIO $ addressFromPrivateKey k pure (k, WalletAddress {accountIndex = n, keyPath = decodeLatin1 $ B32.renderPath path, address = decodeLatin1 $ strEncode a}) diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 4386f507eb..81ae167f16 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -8,29 +8,28 @@ import ChatTests.Utils import Control.Monad (void) 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.Char (toUpper) import Data.Either (isRight) import Data.List (nub) +import Data.Text (Text) import qualified Data.Text as T import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress (..), WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, seedMnemonic) import qualified Simplex.Messaging.Crypto.BIP39 as B39 import qualified Simplex.Messaging.Crypto.Secp256k1 as S import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Eth.Address (addressFromPrivateKey) -import Simplex.Messaging.Util (safeDecodeUtf8) import Test.Hspec hiding (it) import qualified Test.Hspec as Hspec -testPhrase12 :: ByteString -testPhrase12 = B.unwords $ replicate 11 "abandon" <> ["about"] +testPhrase12 :: Text +testPhrase12 = T.unwords $ replicate 11 "abandon" <> ["about"] -testPhrase24 :: ByteString -testPhrase24 = B.unwords $ replicate 23 "abandon" <> ["art"] +testPhrase24 :: Text +testPhrase24 = T.unwords $ replicate 23 "abandon" <> ["art"] -seedEntropy :: ByteString -> BA.ScrubbedBytes -seedEntropy phrase = B39.mnemonicToEntropy . either error id $ B39.parseMnemonic (safeDecodeUtf8 phrase) +seedEntropy :: Text -> BA.ScrubbedBytes +seedEntropy phrase = B39.mnemonicToEntropy . either error id $ B39.parseMnemonic phrase walletAccount :: BA.ScrubbedBytes -> AccountIndex -> IO (AccountKey, WalletAddress) walletAccount entropy n = either (error . show) id <$> deriveAccount entropy n @@ -71,10 +70,10 @@ walletDerivationTests = do Hspec.it "rejects an account index at or above 2^31" $ (void <$> deriveAccount (seedEntropy testPhrase12) 2147483648) `shouldReturn` Left WEIndexTooLarge Hspec.it "round-trips the phrase it was imported from" $ - seedMnemonic (seedEntropy testPhrase24) `shouldBe` Right (safeDecodeUtf8 testPhrase24) + seedMnemonic (seedEntropy testPhrase24) `shouldBe` Right testPhrase24 Hspec.it "accepts only 24 words with a valid checksum" $ do - entropyFromMnemonic (safeDecodeUtf8 testPhrase24) `shouldSatisfy` isRight - entropyFromMnemonic (safeDecodeUtf8 testPhrase12) `shouldBe` Left WEBadMnemonic + entropyFromMnemonic testPhrase24 `shouldSatisfy` isRight + entropyFromMnemonic testPhrase12 `shouldBe` Left WEBadMnemonic entropyFromMnemonic (T.unwords $ replicate 24 "abandon") `shouldBe` Left WEBadMnemonic walletTests :: SpecWith TestParams @@ -111,12 +110,12 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice <## "wallet: this device already has a wallet" alice ##> "/_wallet delete" alice <## "ok" - alice ##> ("/_wallet create mnemonic=" <> B.unpack (B.unwords $ replicate 24 "abandon")) + alice ##> ("/_wallet create mnemonic=" <> unwords (replicate 24 "abandon")) alice <## "wallet: not a valid 24 word recovery phrase" - alice ##> ("/_wallet create mnemonic=" <> map toUpper (B.unpack testPhrase24)) + alice ##> ("/_wallet create mnemonic=" <> map toUpper (T.unpack testPhrase24)) alice <## "wallet, no accounts for this profile" alice ##> "/_wallet export master" - alice <## B.unpack testPhrase24 + alice <## T.unpack testPhrase24 testWalletBind :: HasCallStack => TestParams -> IO () testWalletBind ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do @@ -197,10 +196,10 @@ testWalletAddress ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do testWalletExport :: HasCallStack => TestParams -> IO () testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase24) + alice ##> ("/_wallet create mnemonic=" <> T.unpack testPhrase24) alice <## "wallet, no accounts for this profile" alice ##> "/_wallet export master" - alice <## B.unpack testPhrase24 + alice <## T.unpack testPhrase24 alice ##> "/_wallet bind 1 account=0" alice `accountBound` "0" alice ##> "/_wallet bind 1 account=1" @@ -221,7 +220,7 @@ testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do testWalletImport :: HasCallStack => TestParams -> IO () testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet create mnemonic=" <> B.unpack testPhrase24) + alice ##> ("/_wallet create mnemonic=" <> T.unpack testPhrase24) alice <## "wallet, no accounts for this profile" alice ##> "/_wallet bind 1" alice <## "wallet: the next account is unknown after an import" @@ -298,12 +297,16 @@ testWalletIndexTooLarge ps = withNewTestChat ps "alice" aliceProfile $ \alice -> alice ##> "/_wallet create new" alice <## "wallet, no accounts for this profile" alice ##> "/_wallet address account=2147483648" - alice <## "wallet: account index is too large to harden" + alice <## "wallet: account index must be below 2^31" alice ##> "/_wallet bind 1 account=2147483648" - alice <## "wallet: account index is too large to harden" + alice <## "wallet: account index must be below 2^31" alice ##> "/_wallet export account 1 2147483648" - alice <## "wallet: account index is too large to harden" + alice <## "wallet: account index must be below 2^31" + alice ##> "/_wallet address account=4294967296" + alice <## "bad chat command: Failed reading: empty" + alice ##> "/_wallet bind 1 account=4294967296" + alice <## "bad chat command: Failed reading: empty" alice ##> "/_wallet bind 1 account=2147483647" alice `accountBound` "2147483647" alice ##> "/_wallet bind 1" - alice <## "wallet: account index is too large to harden" + alice <## "wallet: account index must be below 2^31" From 22c3e5d0679e671c44a562fa870b6aeb6660fece Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 24 Sep 2026 17:05:14 +0200 Subject: [PATCH 55/67] iteration 6 --- docs/rfcs/2026-09-10-wallet-keys.md | 6 +++--- src/Simplex/Chat/View.hs | 2 +- tests/WalletTests.hs | 18 +++++++++--------- 3 files changed, 13 insertions(+), 13 deletions(-) diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 9e9a48927d..8f03b4449f 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -46,7 +46,7 @@ The alternative is one account owning several names. A name's owner address is p ### Why the account level is hardened -The alternative is BIP-44's ordinary address level, `m/44'/60'/0'/0/n`, which is what MetaMask enumerates and is therefore the friendlier path. It is not hardened, and [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) has a known weakness there: the extended public key of a parent, together with one non-hardened child's private key, yields the parent private key and from it every sibling. An exported account key is one half, and any wallet that enumerates accounts produces the other. The two levels below an account are not hardened, so the two halves together reveal that account's key; nothing else is derived under an account and the account level itself is hardened, so they reveal no other account's key. This satisfies objectives 2 and 3, and it is worth the loss of MetaMask's default path. +The alternative is BIP-44's ordinary address level, `m/44'/60'/0'/0/n`, which is what MetaMask enumerates and is therefore the friendlier path. It is not hardened, and [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) has a known weakness there: the extended public key of a parent, together with one non-hardened child's private key, yields the parent private key and from it every sibling. An exported account key is one half, and any wallet that enumerates accounts produces the other. The two levels below an account are not hardened, so the two halves together reveal the extended private key at the account level, m/44'/60'/n'; nothing else is derived under an account and the account level itself is hardened, so they reveal no other account's key. This satisfies objectives 2 and 3, and it is worth the loss of MetaMask's default path. ### Why 24 words @@ -56,7 +56,7 @@ Entropy cannot be added to a seed afterwards and the uses of a seed can grow, so An account is bound to at most one chat profile, and a profile to any number of accounts, because a profile can own any number of names. Only one of those names points at the profile as its SimpleX domain name, but which one is recorded by the names layer, not by the wallet: the wallet records only which profile an account belongs to. -Accounts are allocated in order and never reused, because an account the device no longer tracks still owns whatever it holds, and an account can remain unbound. Nothing is bound when a profile is created; an account is bound on first use, when the user buys a name for a profile, so a device on which the user never buys anything has never derived a key. +Accounts are allocated in order and never reused, because an account the device no longer tracks still owns whatever it holds. An account can remain unbound. Nothing is bound when a profile is created; an account is bound on first use, when the user buys a name for a profile, so a device on which the user neither buys anything nor requests an address has never derived a key. An account cannot be bound to a hidden profile, so no name can be bought for a hidden profile. Two things would leak: the master derives every account, so whoever can use any profile can also derive a hidden profile's account keys; and a name is written into the profile's own database row and listed across the device, while a hidden profile is a filter on what is shown, not encryption. Closing either requires changes in the profiles and in the name record, not in the key layout. Incognito is a property of a connection in this app rather than of a profile, so there is nothing at this level to reject; an incognito connection has no profile of its own to bind an account to. @@ -75,7 +75,7 @@ These commands are an internal API, called by the names commands and by any late derived at; without account=, the next free one /_wallet export master the master phrase /_wallet export account the secret of an account the profile holds, - 0x and 64 hex + 0x and 64 hex digits /_wallet delete delete the master entropy and its accounts ``` diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 0882d700b2..a67d2fd42e 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -2816,7 +2816,7 @@ viewChatError isCmd logLevel testView = \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 hold an account" + WEHiddenProfile -> "an account cannot be bound to a hidden profile" WEAccountBound -> "another profile holds this account" WEAccountNotHeld -> "this profile does not hold this account" WECounterUnknown -> "the next account is unknown after an import" diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 81ae167f16..88cd9f4b4e 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -31,8 +31,8 @@ testPhrase24 = T.unwords $ replicate 23 "abandon" <> ["art"] seedEntropy :: Text -> BA.ScrubbedBytes seedEntropy phrase = B39.mnemonicToEntropy . either error id $ B39.parseMnemonic phrase -walletAccount :: BA.ScrubbedBytes -> AccountIndex -> IO (AccountKey, WalletAddress) -walletAccount entropy n = either (error . show) id <$> deriveAccount entropy n +walletAccount :: Text -> AccountIndex -> IO (AccountKey, WalletAddress) +walletAccount phrase n = either (error . show) id <$> deriveAccount (seedEntropy phrase) n addressFromSecret :: String -> IO String addressFromSecret secret = do @@ -50,14 +50,14 @@ accountBound cc idx = (take 1 . words <$> getTermLine cc) `shouldReturn` [idx] walletDerivationTests :: Spec walletDerivationTests = do Hspec.it "derives the addresses another wallet derives for the same phrase" $ do - let addrOf n = address . snd <$> walletAccount (seedEntropy testPhrase12) n + let addrOf n = address . snd <$> walletAccount testPhrase12 n addrOf 0 `shouldReturn` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" addrOf 1 `shouldReturn` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265" Hspec.it "the exported secret is the one another wallet shows for that account" $ do - (k, _) <- walletAccount (seedEntropy testPhrase12) 0 + (k, _) <- walletAccount testPhrase12 0 accountSecret k `shouldBe` "0x1ab42cc412b618bdea3a599e3c9bae199ebf030895b039e9db1e30dafb12b727" Hspec.it "every account has its own address" $ do - addrs <- mapM (fmap (address . snd) . walletAccount (seedEntropy testPhrase12)) [0 .. 9] + addrs <- mapM (fmap (address . snd) . walletAccount testPhrase12) [0 .. 9] length (nub addrs) `shouldBe` 10 Hspec.it "renders a secret whose first byte is zero with 64 hex digits" $ do k <- either error id <$> S.mkPrivateKey (BA.convert $ B.pack ('\0' : replicate 31 '\1')) @@ -65,8 +65,8 @@ walletDerivationTests = do take 4 secret `shouldBe` "0x00" length secret `shouldBe` 66 Hspec.it "renders the path an account is derived at" $ do - (keyPath . snd <$> walletAccount (seedEntropy testPhrase12) 0) `shouldReturn` "m/44'/60'/0'/0/0" - (keyPath . snd <$> walletAccount (seedEntropy testPhrase12) 7) `shouldReturn` "m/44'/60'/7'/0/0" + (keyPath . snd <$> walletAccount testPhrase12 0) `shouldReturn` "m/44'/60'/0'/0/0" + (keyPath . snd <$> walletAccount testPhrase12 7) `shouldReturn` "m/44'/60'/7'/0/0" Hspec.it "rejects an account index at or above 2^31" $ (void <$> deriveAccount (seedEntropy testPhrase12) 2147483648) `shouldReturn` Left WEIndexTooLarge Hspec.it "round-trips the phrase it was imported from" $ @@ -214,7 +214,7 @@ testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do (idx', path', addr', _) <- exportRow <$> getTermLine alice idx' `shouldBe` "1" path' `shouldBe` "m/44'/60'/1'/0/0" - (T.unpack . address . snd <$> walletAccount (seedEntropy testPhrase24) 1) `shouldReturn` addr' + (T.unpack . address . snd <$> walletAccount testPhrase24 1) `shouldReturn` addr' alice ##> "/_wallet address account=1" (words <$> getTermLine alice) `shouldReturn` ["1", "m/44'/60'/1'/0/0", addr'] @@ -274,7 +274,7 @@ testWalletHiddenProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> alice <## "messages are hidden (use /tail to view)" alice <## "profile is hidden" alice ##> "/_wallet bind 2" - alice <## "wallet: a hidden profile cannot hold an account" + alice <## "wallet: an account cannot be bound to a hidden profile" testWalletExportNotHeld :: HasCallStack => TestParams -> IO () testWalletExportNotHeld ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do From c3c3d8e3c9a957728ff573d1c94c3182ba5a97b0 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 25 Sep 2026 08:12:51 +0200 Subject: [PATCH 56/67] self-review iteration 9 --- docs/rfcs/2026-09-10-wallet-keys.md | 10 +++++----- tests/WalletTests.hs | 3 ++- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 8f03b4449f..0b7160af15 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 allocated in order and never reused, because an account the device no longer tracks still owns whatever it holds. An account can remain unbound. Nothing is bound when a profile is created; an account is bound on first use, when the user buys a name for a profile, so a device on which the user neither buys anything nor requests an address has never derived a key. -An account cannot be bound to a hidden profile, so no name can be bought for a hidden profile. Two things would leak: the master derives every account, so whoever can use any profile can also derive a hidden profile's account keys; and a name is written into the profile's own database row and listed across the device, while a hidden profile is a filter on what is shown, not encryption. Closing either requires changes in the profiles and in the name record, not in the key layout. Incognito is a property of a connection in this app rather than of a profile, so there is nothing at this level to reject; an incognito connection has no profile of its own to bind an account to. +An account cannot be bound to a hidden profile, so no name can be bought for a hidden profile. Two things would leak: the master derives every account, so whoever can use any profile can also derive a hidden profile's account keys; and a name is written into the profile's own database row and listed across the device, while a hidden profile is a filter on what is shown, not encryption. Closing either requires changes in the profiles and in the name record, not in the key layout. Incognito is a property of a connection in this app rather than of a profile, so there is nothing at this level to reject; the random profile of an incognito connection is not a chat profile, so it cannot hold an account. ## Commands @@ -89,7 +89,7 @@ BIP-32 marks an index as hardened by setting its top bit, so an index at or abov `address` reads the counter without changing it, so two calls return the same address, and it derives an address for an account the database has no row for, which a device that lost its database requires. One address per call is sufficient: a caller that scans the tree calls it in a loop. -`export account` is rejected unless the profile holds the account, so every exported key belongs to an account that is already bound, which `bind` without an index never returns: the counter is either past it or unknown. An export is a copy and not a transfer: the device still derives what it exported and can still sign with it, so giving an account key away leaves two parties able to act as its owner until whatever it holds is transferred on chain. Signing is not in this change, and when it is added it is a command here that signs and returns a signature, not `export account` followed by signing elsewhere, which would make the narrow export the ordinary path. `delete` leaves whatever the accounts own on chain, recoverable only from the phrase. +`export account` is rejected unless the profile holds the account, so every exported key belongs to an account that is already bound, which `bind` without an index does not return, because the counter is past it or unknown; the exception is a database restored from a backup (known limit 9). An export is a copy and not a transfer: the device still derives what it exported and can still sign with it, so giving an account key away leaves two parties able to act as its owner until whatever it holds is transferred on chain. Signing is not in this change, and when it is added it is a command here that signs and returns a signature, not `export account` followed by signing elsewhere, which would make the narrow export the ordinary path. `delete` leaves whatever the accounts own on chain, recoverable only from the phrase. ```haskell data WalletAddress = WalletAddress {accountIndex :: Word32, keyPath :: Text, address :: Text} @@ -110,9 +110,9 @@ data WalletError Three cases, by how much of the database is restored. -**Only the phrase is left.** A phrase encodes the entropy and nothing else. Which accounts were used is not encoded in it, so the counter starts out unknown and only a scan of the chain sets it; which profile held which account is not encoded in it either, and nothing recovers that. The scan is a sequence of `address` calls, one for each candidate: account 0, then 1, and so on, querying what each address owns and stopping after a run of addresses that own nothing. Querying what an address owns, choosing the length of that run, and recording the results are the caller's responsibility, because the wallet never receives the results. Nothing outside this API adds rows to these tables, so a command added together with the scan writes a row for each account found, unbound, and moves the counter past every account row, including accounts bound by index that the scan does not find. +**Only the phrase is left.** A phrase encodes the entropy and nothing else. Which accounts were used is not encoded in it, so the counter starts out unknown and only a scan of the chain sets it; which profile held which account is not encoded in it either, and nothing recovers that. The scan is a sequence of `address` calls, one for each candidate: account 0, then 1, and so on, querying what each address owns and stopping after a run of addresses that own nothing. Querying what an address owns and choosing the length of that run are the caller's responsibility, because `address` returns only an address. Nothing outside this API adds rows to these tables, so a command added together with the scan writes a row for each account found, unbound, and moves the counter past every account row, including accounts bound by index that the scan does not find. -What the scan finds is unbound, and the user attaches each account to a profile with `bind account=`. The names those accounts own identify them, which is what makes the choice possible at all: the user chooses between names they recognise, not between numbers. Binding changes no key and signs nothing, because ownership does not change, only the profile under which the app shows the account. Pointing a name at that profile's address is a separate signed edit of the name's record. +What the scan finds is unbound, and the user attaches each account to a profile with `bind account=`. The names those accounts own identify them, which is what makes the choice possible at all: the user chooses between names they recognise, not between numbers. Binding changes no key and signs nothing, because ownership does not change, only the profile under which the app shows the account. Pointing a name at that profile's SimpleX address is a separate signed edit of the name's record. **The database is restored from a backup.** It contains the accounts as of the backup and nothing written after it, so its counter is behind if an account was bound after the backup. A counter that is behind is worse than one that is unknown, because it appears valid, so `bind` returns an account that is already in use. Nothing here distinguishes a restored database from a current one, so clearing the counter is the responsibility of whatever restores a database, along with the scan that sets it again. @@ -142,7 +142,7 @@ CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id); Only entropy that nothing can derive is stored: the master, always 32 bytes, since it is generated and imported as 24 words. An account key is never stored, because the master entropy and an account index derive it whenever it is required. So `wallet_accounts` contains what derivation cannot produce: which account indexes are recorded on the device and which profile each belongs to. A row with no `user_id` is an account no profile holds, which is the result of deleting a chat profile and what a scan writes. -`users` is not changed: the mapping is stored in the account row, and the index on `user_id` is not unique, because a profile owns as many accounts as it owns names. One seed per device is enforced by `single_seed` and the unique index on it, which a later change removes with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop an inline constraint without rebuilding the table. Deleting the master deletes its account rows, because an account index without its entropy derives nothing. The migration has no down migration, because a down migration would delete the master entropy, which may have no other copy. A down migration runs when an older app opens a newer database and the user confirms "Downgrade and open chat", and the backup made then is overwritten by the next upgrade. Without a down migration the older app reports that the database is newer than the app, and changes nothing. +`users` is not changed: the mapping is stored in the account row, and the index on `user_id` is not unique, because a profile can hold any number of accounts. One seed per device is enforced by `single_seed` and the unique index on it, which a later change removes with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop an inline constraint without rebuilding the table. Deleting the master deletes its account rows, because an account index without its entropy derives nothing. The migration has no down migration, because a down migration would delete the master entropy, which may have no other copy. A down migration runs when an older app opens a newer database and the user confirms "Downgrade and open chat", and the backup made then is overwritten by the next upgrade. Without a down migration the older app reports that the database is newer than the app, and changes nothing. A null `account_index` marks an account whose key was imported rather than derived. The master phrase does not recover such an account, and the schema must not suggest that it does. Importing one is not implemented here; the column is nullable now so that a row written later is read correctly. That feature also requires storage for the imported secret and an optional link to a seed, because such an account belongs to no seed and must not be deleted with one; on SQLite, making `wallet_seed_id` nullable rebuilds the table. diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 88cd9f4b4e..98520d00f3 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -211,10 +211,11 @@ testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do addr `shouldBe` "0xF278cF59F82eDcf871d630F28EcC8056f25C1cdb" addressFromSecret secret `shouldReturn` addr alice ##> "/_wallet export account 1 1" - (idx', path', addr', _) <- exportRow <$> getTermLine alice + (idx', path', addr', secret') <- exportRow <$> getTermLine alice idx' `shouldBe` "1" path' `shouldBe` "m/44'/60'/1'/0/0" (T.unpack . address . snd <$> walletAccount testPhrase24 1) `shouldReturn` addr' + addressFromSecret secret' `shouldReturn` addr' alice ##> "/_wallet address account=1" (words <$> getTermLine alice) `shouldReturn` ["1", "m/44'/60'/1'/0/0", addr'] From ab88104dbd13062efb485554aa5053c26b404bb2 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 25 Sep 2026 08:31:31 +0200 Subject: [PATCH 57/67] self-review iteration 10 --- cabal.project | 2 +- docs/rfcs/2026-09-10-wallet-keys.md | 4 ++-- scripts/nix/sha256map.nix | 2 +- 3 files changed, 4 insertions(+), 4 deletions(-) diff --git a/cabal.project b/cabal.project index b9423c022f..c107bcc75e 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 4719c432589c7b2f964d10d0502ee77701ef78fe + tag: 355cec2eb434fe29a8f947c8196e6936a1875932 source-repository-package type: git diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 0b7160af15..fe8cce2ac8 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -27,7 +27,7 @@ A device has one piece of [BIP-39](https://github.com/bitcoin/bips/blob/master/b Each thing the device owns on chain is assigned an **account index**, and the key at that index is the **account key**. Names are the first use: one name, one account, and that account's address is the owner address in the name record. Which chat profile an account belongs to is a mapping in the database. No path contains a profile, so no profile data is an input to the derivation, and the mapping can be changed without changing any key. ``` -master seed, 24 words the only thing to back up +master seed, 24 words the only key material to back up └── m/44'/60'/n'/0/0 account n, n >= 0 ``` @@ -114,7 +114,7 @@ Three cases, by how much of the database is restored. What the scan finds is unbound, and the user attaches each account to a profile with `bind account=`. The names those accounts own identify them, which is what makes the choice possible at all: the user chooses between names they recognise, not between numbers. Binding changes no key and signs nothing, because ownership does not change, only the profile under which the app shows the account. Pointing a name at that profile's SimpleX address is a separate signed edit of the name's record. -**The database is restored from a backup.** It contains the accounts as of the backup and nothing written after it, so its counter is behind if an account was bound after the backup. A counter that is behind is worse than one that is unknown, because it appears valid, so `bind` returns an account that is already in use. Nothing here distinguishes a restored database from a current one, so clearing the counter is the responsibility of whatever restores a database, along with the scan that sets it again. +**The database is restored from a backup.** It contains the accounts as of the backup and nothing written after it, so its counter is behind if an account was bound after the backup. A counter that is behind is worse than one that is unknown, because it appears valid, so `bind` can return an account that is already in use. Nothing here distinguishes a restored database from a current one, so clearing the counter is the responsibility of whatever restores a database, along with the scan that sets it again. **The database is current.** It records which profile holds which account, so the user is asked nothing. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 2defe00188..e7f4ab019f 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."4719c432589c7b2f964d10d0502ee77701ef78fe" = "014iy7a0jqxhky62140smhj1jsr24d1xlr94hcv1fpngw84bn0s4"; + "https://github.com/simplex-chat/simplexmq.git"."355cec2eb434fe29a8f947c8196e6936a1875932" = "0rfq20a1vvzw001zgw5df11xkdvh8qxwwanr08cz84g3xbi1ldvf"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; From 3130a7c556d675131caf081c6ac4a5a270491839 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 25 Sep 2026 08:34:31 +0200 Subject: [PATCH 58/67] repin --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index c107bcc75e..3829a02d35 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 355cec2eb434fe29a8f947c8196e6936a1875932 + tag: 2bfc408a58d4a0bed084816f9d885527c499e6a4 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index e7f4ab019f..78b783e70f 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."355cec2eb434fe29a8f947c8196e6936a1875932" = "0rfq20a1vvzw001zgw5df11xkdvh8qxwwanr08cz84g3xbi1ldvf"; + "https://github.com/simplex-chat/simplexmq.git"."2bfc408a58d4a0bed084816f9d885527c499e6a4" = "1ccq3qvhd2ml1dwzw26j9akhpnxdp7yjdsjpkg7vhglzmhyvzzd4"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; From acf06724ec94756c37da6e8d6814466ad0d22579 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 25 Sep 2026 11:08:20 +0200 Subject: [PATCH 59/67] rng preserve --- src/Simplex/Chat/Library/Commands.hs | 4 +++- src/Simplex/Chat/Wallet.hs | 10 +++++----- tests/WalletTests.hs | 18 ++++++++++++------ 3 files changed, 20 insertions(+), 12 deletions(-) diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index f7ac66e26f..83cff093d7 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -6035,7 +6035,9 @@ withWalletStore :: (DB.Connection -> IO (Either WalletError a)) -> CM a withWalletStore action = liftWallet =<< withFastStore' action seedAccount :: WalletSeed -> AccountIndex -> CM (AccountKey, WalletAddress) -seedAccount WalletSeed {wsEntropy} n = liftError' (ChatError . CEWallet) (deriveAccount wsEntropy n) +seedAccount WalletSeed {wsEntropy} n = do + g <- asks random + liftError' (ChatError . CEWallet) (deriveAccount g wsEntropy n) chatCommandP :: Parser ChatCommand chatCommandP = diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index c79a63c39d..8baddb2333 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -78,13 +78,13 @@ entropyFromMnemonic phrase = case B39.parseMnemonic phrase of seedMnemonic :: BA.ScrubbedBytes -> Either WalletError Text seedMnemonic = bimap WEDerivation (decodeLatin1 . B39.mnemonicPhrase) . B39.entropyToMnemonic -deriveAccount :: BA.ScrubbedBytes -> AccountIndex -> IO (Either WalletError (AccountKey, WalletAddress)) -deriveAccount entropy n = runExceptT $ do +deriveAccount :: TVar ChaChaDRG -> BA.ScrubbedBytes -> AccountIndex -> IO (Either WalletError (AccountKey, WalletAddress)) +deriveAccount g entropy n = runExceptT $ do path <- liftEither $ accountPath n m <- liftEitherWith WEDerivation $ B39.entropyToMnemonic entropy - master <- liftError' WEDerivation $ B32.masterKey (B39.mnemonicToSeed m "") - k <- B32.xkKey <$> liftError' WEDerivation (B32.derivePath master path) - a <- liftIO $ addressFromPrivateKey k + master <- liftError' WEDerivation $ B32.masterKey g (B39.mnemonicToSeed m "") + k <- B32.xkKey <$> liftError' WEDerivation (B32.derivePath g master path) + a <- liftIO $ addressFromPrivateKey g k pure (k, WalletAddress {accountIndex = n, keyPath = decodeLatin1 $ B32.renderPath path, address = decodeLatin1 $ strEncode a}) accountSecret :: AccountKey -> Text diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 98520d00f3..dab11e43c1 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -15,6 +15,7 @@ import Data.List (nub) import Data.Text (Text) import qualified Data.Text as T import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress (..), WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, seedMnemonic) +import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.BIP39 as B39 import qualified Simplex.Messaging.Crypto.Secp256k1 as S import Simplex.Messaging.Encoding.String (strEncode) @@ -32,12 +33,15 @@ seedEntropy :: Text -> BA.ScrubbedBytes seedEntropy phrase = B39.mnemonicToEntropy . either error id $ B39.parseMnemonic phrase walletAccount :: Text -> AccountIndex -> IO (AccountKey, WalletAddress) -walletAccount phrase n = either (error . show) id <$> deriveAccount (seedEntropy phrase) n +walletAccount phrase n = do + g <- C.newRandom + either (error . show) id <$> deriveAccount g (seedEntropy phrase) n addressFromSecret :: String -> IO String addressFromSecret secret = do - k <- either error id <$> S.mkPrivateKey (either error id $ BAE.convertFromBase BAE.Base16 (B.drop 2 $ B.pack secret)) - B.unpack . strEncode <$> addressFromPrivateKey k + g <- C.newRandom + k <- either error id <$> S.mkPrivateKey g (either error id $ BAE.convertFromBase BAE.Base16 (B.drop 2 $ B.pack secret)) + B.unpack . strEncode <$> addressFromPrivateKey g k exportRow :: HasCallStack => String -> (String, String, String, String) exportRow row = case words row of @@ -60,15 +64,17 @@ walletDerivationTests = do addrs <- mapM (fmap (address . snd) . walletAccount testPhrase12) [0 .. 9] length (nub addrs) `shouldBe` 10 Hspec.it "renders a secret whose first byte is zero with 64 hex digits" $ do - k <- either error id <$> S.mkPrivateKey (BA.convert $ B.pack ('\0' : replicate 31 '\1')) + g <- C.newRandom + k <- either error id <$> S.mkPrivateKey g (BA.convert $ B.pack ('\0' : replicate 31 '\1')) let secret = T.unpack $ accountSecret k take 4 secret `shouldBe` "0x00" length secret `shouldBe` 66 Hspec.it "renders the path an account is derived at" $ do (keyPath . snd <$> walletAccount testPhrase12 0) `shouldReturn` "m/44'/60'/0'/0/0" (keyPath . snd <$> walletAccount testPhrase12 7) `shouldReturn` "m/44'/60'/7'/0/0" - Hspec.it "rejects an account index at or above 2^31" $ - (void <$> deriveAccount (seedEntropy testPhrase12) 2147483648) `shouldReturn` Left WEIndexTooLarge + Hspec.it "rejects an account index at or above 2^31" $ do + g <- C.newRandom + (void <$> deriveAccount g (seedEntropy testPhrase12) 2147483648) `shouldReturn` Left WEIndexTooLarge Hspec.it "round-trips the phrase it was imported from" $ seedMnemonic (seedEntropy testPhrase24) `shouldBe` Right testPhrase24 Hspec.it "accepts only 24 words with a valid checksum" $ do From 5db99e96b9762f5b41d97603ef0444a43460cc88 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 25 Sep 2026 12:12:37 +0200 Subject: [PATCH 60/67] refactor WalletSeed type, see mq wip --- bots/api/TYPES.md | 4 +- docs/rfcs/2026-09-10-wallet-keys.md | 9 +- .../types/typescript/src/types.ts | 8 +- .../src/simplex_chat/types/_types.py | 8 +- src/Simplex/Chat/Library/Commands.hs | 46 +++++----- .../Migrations/M20260924_wallet_seeds.hs | 1 + .../Store/Postgres/Migrations/chat_schema.sql | 2 + .../Migrations/M20260924_wallet_seeds.hs | 1 + .../SQLite/Migrations/chat_query_plans.txt | 4 +- .../Store/SQLite/Migrations/chat_schema.sql | 1 + src/Simplex/Chat/Store/Wallets.hs | 89 ++++++++++--------- src/Simplex/Chat/View.hs | 7 +- src/Simplex/Chat/Wallet.hs | 54 +++++------ tests/WalletTests.hs | 33 ++++--- 14 files changed, 137 insertions(+), 130 deletions(-) diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index 5aef0a6102..d01fc3b4b4 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -4665,8 +4665,8 @@ AccountNotHeld: CounterUnknown: - type: "counterUnknown" -IndexTooLarge: -- type: "indexTooLarge" +AccountsExhausted: +- type: "accountsExhausted" Derivation: - type: "derivation" diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index fe8cce2ac8..4619c1229e 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -85,7 +85,7 @@ A command that acts on a profile's accounts names the profile and is rejected wh `bind` without `account=` binds the account at a counter on the master, which is a high-water mark and not a count of bound accounts, and returns the bound account's index, path and address. With `account=` it binds that account, which is how an account found by a scan is attached to the profile it belongs to, and it is rejected for an account another profile holds. After an import the counter is unknown rather than zero, because the phrase does not encode how many accounts it has been used for, so binding the next account is rejected until a scan sets the counter, while binding a known account is still allowed. -BIP-32 marks an index as hardened by setting its top bit, so an index at or above 2^31 already has that bit set and derives the same key as the index 2^31 below it: account 2^31 is account 0. That is a collision, not a loss of hardening, and it would put one key under two account indexes. Every index at or above 2^31 is rejected, including one read from the counter; the simplexmq function that builds the path rejects it too, and the columns have CHECK constraints for that bound, so later writes cannot exceed it. The counter's bound is one higher than an account's, because it contains the next index to bind, and 2^31 there means the counter has passed every index that can be hardened. +BIP-32 marks an index as hardened by setting its top bit, so an index at or above 2^31 already has that bit set and derives the same key as the index 2^31 below it: account 2^31 is account 0. That is a collision, not a loss of hardening, and it would put one key under two account indexes. An account index is a simplexmq type that only holds values below 2^31, so the command parser rejects 2^31 and above as a bad command, the columns have CHECK constraints for that bound, and reading a row that violates it is an error. The counter's bound is one higher than an account's, because it contains the next index to bind, and 2^31 there means the counter has passed every index that can be hardened, which `bind` without an index reports. `address` reads the counter without changing it, so two calls return the same address, and it derives an address for an account the database has no row for, which a device that lost its database requires. One address per call is sufficient: a caller that scans the tree calls it in a loop. @@ -102,7 +102,7 @@ data WalletError | WEAccountBound -- bind, on an account another profile holds | WEAccountNotHeld -- export account, on an account the profile does not hold | WECounterUnknown -- the counter is not set yet, after an import - | WEIndexTooLarge -- at or above 2^31 + | WEAccountsExhausted -- bind without an index, when the counter has passed every index | WEDerivation {derivationError :: String} -- BIP-32 or BIP-39 derivation failed ``` @@ -124,6 +124,7 @@ 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), + master BLOB NOT NULL CHECK (length(master) = 64), next_account_index INTEGER CHECK (next_account_index BETWEEN 0 AND 2147483648), -- null means not known yet single_seed INTEGER NOT NULL DEFAULT 1 ) STRICT; @@ -140,7 +141,7 @@ CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_a CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id); ``` -Only entropy that nothing can derive is stored: the master, always 32 bytes, since it is generated and imported as 24 words. An account key is never stored, because the master entropy and an account index derive it whenever it is required. So `wallet_accounts` contains what derivation cannot produce: which account indexes are recorded on the device and which profile each belongs to. A row with no `user_id` is an account no profile holds, which is the result of deleting a chat profile and what a scan writes. +The master entropy, always 32 bytes since it is generated and imported as 24 words, is stored with the BIP-32 master key it derives, the 32-byte private key followed by the 32-byte chain code. Reading the row recomputes the master key from the entropy and rejects a row where the two do not match, so a wallet read from the database always derives accounts. An account key is never stored, because the master key and an account index derive it whenever it is required. So `wallet_accounts` contains what derivation cannot produce: which account indexes are recorded on the device and which profile each belongs to. A row with no `user_id` is an account no profile holds, which is the result of deleting a chat profile and what a scan writes. `users` is not changed: the mapping is stored in the account row, and the index on `user_id` is not unique, because a profile can hold any number of accounts. One seed per device is enforced by `single_seed` and the unique index on it, which a later change removes with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop an inline constraint without rebuilding the table. Deleting the master deletes its account rows, because an account index without its entropy derives nothing. The migration has no down migration, because a down migration would delete the master entropy, which may have no other copy. A down migration runs when an older app opens a newer database and the user confirms "Downgrade and open chat", and the backup made then is overwritten by the next upgrade. Without a down migration the older app reports that the database is newer than the app, and changes nothing. @@ -183,6 +184,6 @@ A null `account_index` marks an account whose key was imported rather than deriv 1. **Vectors.** The two addresses above are derived from `abandon ... about`, as is account 0's secret, pinned to the value another wallet shows for it. A 24 word phrase imported through the command derives a pinned address end to end, so a change of path fails here rather than in a release, and the account a command names is the account whose key is returned. 2. **Isolation.** Ten accounts' addresses are all different, and the paths of accounts 0 and 7 have a hardened account component. -3. **Rejections.** A second generate; a phrase that is not 24 valid words; `bind`, `delete` and `export master` on a device with no wallet; `bind` on a hidden profile, on an account another profile holds, and on an imported master whose counter is unknown; `export account` for an account the profile does not hold; index 2^31, on `address`, `bind` and `export account` alike; an index of 2^32 or more is rejected as a bad command. +3. **Rejections.** A second generate; a phrase that is not 24 valid words; `bind`, `delete` and `export master` on a device with no wallet; `bind` on a hidden profile, on an account another profile holds, and on an imported master whose counter is unknown; `export account` for an account the profile does not hold; `bind` without an index once the counter has passed 2^31 - 1; an index of 2^31 or more is rejected as a bad command on `address`, `bind` and `export account` alike. 4. **Binding and reads.** Several accounts are bound to one profile, each `bind` returns the account it bound, and that profile's accounts can be exported; binding an account by index moves the counter past it so the next one does not collide, and never moves it back; `bind account=` binds an account after an import; an account left unbound by deleting its profile is bound to another profile; and two consecutive `address` calls return the same address without changing the counter, and derive an address for an account with no row. 5. **Encoding and persistence.** An account secret whose first byte is zero is rendered with 64 hex digits; the wallet, its accounts, the counter and the phrase persist across a restart; and deleting the wallet deletes its accounts and resets the counter. diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index c39b736d0b..9cfc5f40c5 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -5315,7 +5315,7 @@ export type WalletError = | WalletError.AccountBound | WalletError.AccountNotHeld | WalletError.CounterUnknown - | WalletError.IndexTooLarge + | WalletError.AccountsExhausted | WalletError.Derivation export namespace WalletError { @@ -5327,7 +5327,7 @@ export namespace WalletError { | "accountBound" | "accountNotHeld" | "counterUnknown" - | "indexTooLarge" + | "accountsExhausted" | "derivation" interface Interface { @@ -5362,8 +5362,8 @@ export namespace WalletError { type: "counterUnknown" } - export interface IndexTooLarge extends Interface { - type: "indexTooLarge" + export interface AccountsExhausted extends Interface { + type: "accountsExhausted" } export interface Derivation extends Interface { 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 1207c36110..a945f984a1 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -3743,8 +3743,8 @@ class WalletError_accountNotHeld(TypedDict): class WalletError_counterUnknown(TypedDict): type: Literal["counterUnknown"] -class WalletError_indexTooLarge(TypedDict): - type: Literal["indexTooLarge"] +class WalletError_accountsExhausted(TypedDict): + type: Literal["accountsExhausted"] class WalletError_derivation(TypedDict): type: Literal["derivation"] @@ -3758,11 +3758,11 @@ WalletError = ( | WalletError_accountBound | WalletError_accountNotHeld | WalletError_counterUnknown - | WalletError_indexTooLarge + | WalletError_accountsExhausted | WalletError_derivation ) -WalletError_Tag = Literal["noMaster", "masterExists", "badMnemonic", "hiddenProfile", "accountBound", "accountNotHeld", "counterUnknown", "indexTooLarge", "derivation"] +WalletError_Tag = Literal["noMaster", "masterExists", "badMnemonic", "hiddenProfile", "accountBound", "accountNotHeld", "counterUnknown", "accountsExhausted", "derivation"] class XFTPErrorType_BLOCK(TypedDict): type: Literal["BLOCK"] diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 83cff093d7..46dc2173ec 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -65,7 +65,7 @@ import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeIss import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Badges.Service (BadgeBalance (..), BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), BadgeStatement (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..), currentBadgeServiceVersion) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) -import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress, WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, newSeedEntropy, seedMnemonic) +import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress, WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, newSeedEntropy, newWalletMaster, seedMnemonic) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -96,7 +96,7 @@ import Simplex.Chat.Store.Messages import Simplex.Chat.Store.NoteFolders import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared -import Simplex.Chat.Store.Wallets (WalletSeed (..), accountHeldBy, bindAccount, createWalletSeed, deleteWalletSeed, getUserAccounts, getWalletSeed, resolveAccount) +import Simplex.Chat.Store.Wallets (Wallet (..), accountHeldBy, bindAccount, createWallet, deleteWallet, getUserAccounts, getWallet, resolveAccount) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -116,6 +116,7 @@ import Simplex.Messaging.Agent.Store.Interface (getCurrentMigrations) import Simplex.Messaging.Client (NetworkConfig (..), NetworkRequestMode (..), NetworkTimeout (..), SMPWebPortServers (..), SocksMode (SMAlways), pattern NRMInteractive, textToHostMode) import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.ShortLink as SL +import Simplex.Messaging.Crypto.BIP44 (mkAccountIndex) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Crypto.Ratchet (E2ERatchetParamsUri (..), InitialKeys (..), PQEncryption (..), PQSupport (..), pattern IKPQOff, pattern IKPQOn, pattern PQSupportOff, pattern PQSupportOn) @@ -1500,35 +1501,36 @@ processChatCommand cxt nm = \case connId <- withAgent $ \a -> sendServiceReplyAsync a "" (aUserId user) invId (LB.toStrict $ J.encode responseData) pure $ CRServiceReplyAccepted user (AgentConnId connId) APIGetWallet userId -> withUserId userId $ \user -> - CRWallet user <$> withFastStore' (\db -> getWalletSeed db >>= mapM (\WalletSeed {wsId} -> getUserAccounts db wsId userId)) + CRWallet user <$> withFastStore (\db -> getWallet db >>= mapM (\Wallet {walletId} -> liftIO $ getUserAccounts db walletId userId)) APICreateWallet mnemonic_ -> withUser $ \user -> do - seed_ <- withFastStore' getWalletSeed - when (isJust seed_) $ throwWalletError WEMasterExists + wallet_ <- withFastStore getWallet + when (isJust wallet_) $ throwWalletError WEMasterExists -- the counter starts at 0 for a generated seed and is unknown for an imported one (entropy, nextAccount) <- case mnemonic_ of Nothing -> (,Just 0) <$> (asks random >>= atomically . newSeedEntropy) Just phrase -> (,Nothing) <$> liftWallet (entropyFromMnemonic phrase) - created <- withFastStore' $ \db -> createWalletSeed db entropy nextAccount + master <- liftWallet $ newWalletMaster entropy + created <- withFastStore' $ \db -> createWallet db master nextAccount unless created $ throwWalletError WEMasterExists pure $ CRWallet user (Just []) APIBindWalletAccount userId accountIdx_ -> withUserId userId $ \user@User {viewPwdHash} -> do when (isJust viewPwdHash) $ throwWalletError WEHiddenProfile - (seed, n) <- withWalletStore $ \db -> bindAccount db userId accountIdx_ - CRWalletAddress user . snd <$> seedAccount seed n + (wallet, n) <- withWalletStore $ \db -> bindAccount db userId accountIdx_ + CRWalletAddress user . snd <$> walletAccount wallet n APIGetWalletAddress accountIdx_ -> withUser $ \user -> do - (seed, n) <- withWalletStore (`resolveAccount` accountIdx_) - CRWalletAddress user . snd <$> seedAccount seed n + (wallet, n) <- withWalletStore (`resolveAccount` accountIdx_) + CRWalletAddress user . snd <$> walletAccount wallet n APIExportWalletMnemonic -> withUser $ \user -> do - WalletSeed {wsEntropy} <- withFastStore' getWalletSeed >>= maybe (throwWalletError WENoMaster) pure - CRWalletMnemonic user <$> liftWallet (seedMnemonic wsEntropy) + Wallet {walletMaster} <- withFastStore getWallet >>= maybe (throwWalletError WENoMaster) pure + pure $ CRWalletMnemonic user (seedMnemonic walletMaster) APIExportWalletAccount userId n -> withUserId userId $ \user -> do - (seed@WalletSeed {wsId}, _) <- withWalletStore (`resolveAccount` Just n) - held <- withFastStore' $ \db -> accountHeldBy db wsId userId n + (wallet@Wallet {walletId}, _) <- withWalletStore (`resolveAccount` Just n) + held <- withFastStore' $ \db -> accountHeldBy db walletId userId n unless held $ throwWalletError WEAccountNotHeld - (k, a) <- seedAccount seed n + (k, a) <- walletAccount wallet n pure $ CRWalletAccountSecret user a (accountSecret k) APIDeleteWallet -> withUser_ $ do - deleted <- withFastStore' deleteWalletSeed + deleted <- withFastStore' deleteWallet unless deleted $ throwWalletError WENoMaster ok_ APISendCallInvitation contactId callType -> withUser $ \user -> do @@ -6031,13 +6033,13 @@ throwWalletError = throwChatError . CEWallet liftWallet :: Either WalletError a -> CM a liftWallet = liftEitherWith (ChatError . CEWallet) -withWalletStore :: (DB.Connection -> IO (Either WalletError a)) -> CM a -withWalletStore action = liftWallet =<< withFastStore' action +withWalletStore :: (DB.Connection -> ExceptT StoreError IO (Either WalletError a)) -> CM a +withWalletStore action = liftWallet =<< withFastStore action -seedAccount :: WalletSeed -> AccountIndex -> CM (AccountKey, WalletAddress) -seedAccount WalletSeed {wsEntropy} n = do +walletAccount :: Wallet -> AccountIndex -> CM (AccountKey, WalletAddress) +walletAccount Wallet {walletMaster} n = do g <- asks random - liftError' (ChatError . CEWallet) (deriveAccount g wsEntropy n) + liftError' (ChatError . CEWallet) (deriveAccount g walletMaster n) chatCommandP :: Parser ChatCommand chatCommandP = @@ -6714,7 +6716,7 @@ chatCommandP = char_ = optional . A.char accountIndexP = do i <- A.decimal - if i <= toInteger (maxBound :: AccountIndex) then pure (fromInteger i) else fail "account index too large" + maybe (fail "account index too large") pure $ if i <= toInteger (maxBound :: Word32) then mkAccountIndex (fromInteger i) else Nothing displayNameP :: Parser Text displayNameP = safeDecodeUtf8 <$> displayNameP_ diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs index e32d0dc2f5..eded0fea16 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs @@ -12,6 +12,7 @@ m20260924_wallet_seeds = CREATE TABLE wallet_seeds ( wallet_seed_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, entropy BYTEA NOT NULL CHECK (length(entropy) = 32), + master BYTEA NOT NULL CHECK (length(master) = 64), next_account_index BIGINT CHECK (next_account_index BETWEEN 0 AND 2147483648), single_seed SMALLINT NOT NULL DEFAULT 1 ); diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 1dc62f53db..1b32e179e5 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1754,9 +1754,11 @@ ALTER TABLE test_chat_schema.wallet_accounts ALTER COLUMN wallet_account_id ADD CREATE TABLE test_chat_schema.wallet_seeds ( wallet_seed_id bigint NOT NULL, entropy bytea NOT NULL, + master bytea NOT NULL, next_account_index bigint, single_seed smallint DEFAULT 1 NOT NULL, CONSTRAINT wallet_seeds_entropy_check CHECK ((length(entropy) = 32)), + CONSTRAINT wallet_seeds_master_check CHECK ((length(master) = 64)), CONSTRAINT wallet_seeds_next_account_index_check CHECK (((next_account_index >= 0) AND (next_account_index <= '2147483648'::bigint))) ); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs index dec9eb8cfb..fb9f95b429 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs @@ -11,6 +11,7 @@ m20260924_wallet_seeds = CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, entropy BLOB NOT NULL CHECK (length(entropy) = 32), + master BLOB NOT NULL CHECK (length(master) = 64), next_account_index INTEGER CHECK (next_account_index BETWEEN 0 AND 2147483648), single_seed INTEGER NOT NULL DEFAULT 1 ) STRICT; diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt index fa121c4719..a2a70276d7 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_query_plans.txt @@ -2049,7 +2049,7 @@ Query: Plan: Query: - INSERT INTO wallet_seeds (entropy, next_account_index) VALUES (?, ?) + INSERT INTO wallet_seeds (entropy, master, next_account_index) VALUES (?, ?, ?) ON CONFLICT (single_seed) DO NOTHING RETURNING wallet_seed_id @@ -7820,7 +7820,7 @@ Query: SELECT via_contact_uri, via_contact_uri_hash FROM connections WHERE conne Plan: SEARCH connections USING INTEGER PRIMARY KEY (rowid=?) -Query: SELECT wallet_seed_id, entropy, next_account_index FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1 +Query: SELECT wallet_seed_id, entropy, master, next_account_index FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1 Plan: SCAN 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 66c08c5373..a4d619067b 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -984,6 +984,7 @@ CREATE TABLE badge_code_redemptions( CREATE TABLE wallet_seeds( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, entropy BLOB NOT NULL CHECK(length(entropy) = 32), + master BLOB NOT NULL CHECK(length(master) = 64), next_account_index INTEGER CHECK(next_account_index BETWEEN 0 AND 2147483648), single_seed INTEGER NOT NULL DEFAULT 1 ) STRICT; diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index 6b4d2fb776..95a8b839da 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -3,13 +3,14 @@ {-# LANGUAGE NamedFieldPuns #-} {-# LANGUAGE OverloadedStrings #-} {-# LANGUAGE QuasiQuotes #-} +{-# LANGUAGE TupleSections #-} module Simplex.Chat.Store.Wallets ( SeedId, - WalletSeed (..), - getWalletSeed, - createWalletSeed, - deleteWalletSeed, + Wallet (..), + getWallet, + createWallet, + deleteWallet, resolveAccount, getUserAccounts, accountHeldBy, @@ -17,17 +18,21 @@ module Simplex.Chat.Store.Wallets ) where -import Control.Applicative ((<|>)) -import Control.Monad (unless) 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 (AccountIndex, WalletError (..), checkAccountIndex) +import Data.Word (Word32) +import Simplex.Chat.Store.Shared (StoreError (..)) +import Simplex.Chat.Wallet (AccountIndex, WalletError (..)) import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.DB as DB +import Simplex.Messaging.Crypto.BIP32 (WalletMaster, masterBytes, masterEntropy, parseWalletMaster) +import Simplex.Messaging.Crypto.BIP39 (unEntropy) +import Simplex.Messaging.Crypto.BIP44 (mkAccountIndex, unAccountIndex) +import Simplex.Messaging.Util (liftEitherWith) #if defined(dbPostgres) import Database.PostgreSQL.Simple (Only (..)) @@ -39,42 +44,44 @@ import Database.SQLite.Simple.QQ (sql) type SeedId = Int64 -data WalletSeed = WalletSeed - { wsId :: SeedId, - wsEntropy :: BA.ScrubbedBytes, - wsNextAccount :: Maybe AccountIndex +data Wallet = Wallet + { walletId :: SeedId, + walletMaster :: WalletMaster, + nextAccount :: Maybe Word32 } -toSeed :: (SeedId, ByteString, Maybe AccountIndex) -> WalletSeed -toSeed (wsId, entropy, wsNextAccount) = WalletSeed {wsId, wsEntropy = BA.convert entropy, wsNextAccount} +getWallet :: DB.Connection -> ExceptT StoreError IO (Maybe Wallet) +getWallet db = + liftIO (maybeFirstRow id $ DB.query_ db "SELECT wallet_seed_id, entropy, master, next_account_index FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1") + >>= mapM toWallet + where + toWallet :: (SeedId, ByteString, ByteString, Maybe Word32) -> ExceptT StoreError IO Wallet + toWallet (walletId, entropy, master, nextAccount) = + liftEitherWith SEInternalError $ (\walletMaster -> Wallet {walletId, walletMaster, nextAccount}) <$> parseWalletMaster (BA.convert entropy) (BA.convert master) -getWalletSeed :: DB.Connection -> IO (Maybe WalletSeed) -getWalletSeed db = - maybeFirstRow toSeed $ - DB.query_ db "SELECT wallet_seed_id, entropy, next_account_index FROM wallet_seeds ORDER BY wallet_seed_id LIMIT 1" - -createWalletSeed :: DB.Connection -> BA.ScrubbedBytes -> Maybe AccountIndex -> IO Bool -createWalletSeed db entropy nextAccount = +createWallet :: DB.Connection -> WalletMaster -> Maybe Word32 -> IO Bool +createWallet db master nextAccount = rowReturned $ DB.query db [sql| - INSERT INTO wallet_seeds (entropy, next_account_index) VALUES (?, ?) + INSERT INTO wallet_seeds (entropy, master, next_account_index) VALUES (?, ?, ?) ON CONFLICT (single_seed) DO NOTHING RETURNING wallet_seed_id |] - (DB.Binary (BA.convert entropy :: ByteString), nextAccount) + (DB.Binary (BA.convert (unEntropy $ masterEntropy master) :: ByteString), DB.Binary (BA.convert (masterBytes master) :: ByteString), nextAccount) -deleteWalletSeed :: DB.Connection -> IO Bool -deleteWalletSeed db = +deleteWallet :: DB.Connection -> IO Bool +deleteWallet db = rowReturned $ DB.query_ db "DELETE FROM wallet_seeds RETURNING wallet_seed_id" -resolveAccount :: DB.Connection -> Maybe AccountIndex -> IO (Either WalletError (WalletSeed, AccountIndex)) -resolveAccount db accountIdx_ = runExceptT $ do - seed@WalletSeed {wsNextAccount} <- ExceptT $ maybe (Left WENoMaster) Right <$> getWalletSeed db - n <- liftEither $ maybe (Left WECounterUnknown) Right (accountIdx_ <|> wsNextAccount) - liftEither $ checkAccountIndex n - pure (seed, n) +resolveAccount :: DB.Connection -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (Wallet, AccountIndex)) +resolveAccount db accountIdx_ = + getWallet db >>= \case + Nothing -> pure $ Left WENoMaster + Just w@Wallet {nextAccount} -> pure $ (w,) <$> maybe next Right accountIdx_ + where + next = maybe (Left WECounterUnknown) (maybe (Left WEAccountsExhausted) Right . mkAccountIndex) nextAccount getUserAccounts :: DB.Connection -> SeedId -> UserId -> IO [AccountIndex] getUserAccounts db sId userId = @@ -94,16 +101,16 @@ accountUser db sId n = maybeFirstRow fromOnly $ DB.query db "SELECT user_id FROM wallet_accounts WHERE wallet_seed_id = ? AND account_index = ?" (sId, n) -bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> IO (Either WalletError (WalletSeed, AccountIndex)) -bindAccount db userId accountIdx_ = runExceptT $ do - r@(WalletSeed {wsId}, n) <- ExceptT $ resolveAccount db accountIdx_ - held <- liftIO $ accountUser db wsId n >>= \case - Just (Just heldBy) -> pure $ heldBy == userId - Just Nothing -> setAccountUser db wsId userId n - Nothing -> True <$ insertAccount db wsId userId n - unless held $ throwError WEAccountBound - liftIO $ raiseNextAccount db wsId n - pure r +bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (Wallet, AccountIndex)) +bindAccount db userId accountIdx_ = resolveAccount db accountIdx_ >>= either (pure . Left) (liftIO . bind) + where + bind r@(Wallet {walletId}, n) = do + held <- + accountUser db walletId n >>= \case + Just (Just heldBy) -> pure $ heldBy == userId + Just Nothing -> setAccountUser db walletId userId n + Nothing -> True <$ insertAccount db walletId userId n + if held then Right r <$ raiseNextAccount db walletId n else pure $ Left WEAccountBound setAccountUser :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bool setAccountUser db sId userId n = @@ -128,7 +135,7 @@ raiseNextAccount db sId n = UPDATE wallet_seeds SET next_account_index = ? WHERE wallet_seed_id = ? AND next_account_index IS NOT NULL AND next_account_index <= ? |] - (n + 1, sId, n) + (unAccountIndex n + 1, sId, n) insertAccount :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO () insertAccount db sId userId n = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index a67d2fd42e..5e50c4bf13 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -69,6 +69,7 @@ import Simplex.Messaging.Agent.Protocol import Simplex.Messaging.Agent.Store.Entity import Simplex.Messaging.Client (SMPProxyFallback, SMPProxyMode (..), SocksMode (..)) import qualified Simplex.Messaging.Crypto as C +import Simplex.Messaging.Crypto.BIP44 (unAccountIndex) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.Ratchet as CR import Simplex.Messaging.Encoding @@ -199,7 +200,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te 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)] + Just accounts -> [plain $ "accounts: " <> T.intercalate ", " (map (tshow . unAccountIndex) 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] @@ -1112,7 +1113,7 @@ viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of walletAddressRow :: WalletAddress -> StyledString walletAddressRow WalletAddress {accountIndex, keyPath, address} = - plain $ tshow accountIndex <> " " <> keyPath <> " " <> address + plain $ tshow (unAccountIndex accountIndex) <> " " <> keyPath <> " " <> address viewContactsList :: [Contact] -> [StyledString] viewContactsList = @@ -2820,7 +2821,7 @@ viewChatError isCmd logLevel testView = \case WEAccountBound -> "another profile holds this account" WEAccountNotHeld -> "this profile does not hold this account" WECounterUnknown -> "the next account is unknown after an import" - WEIndexTooLarge -> "account index must be below 2^31" + WEAccountsExhausted -> "every account index is used" WEDerivation e -> "derivation failed: " <> T.pack e in [plain $ "wallet: " <> reason] CENotResolvedLocally -> ["no matching chat found, name resolution is disabled"] diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index 8baddb2333..0cfb909416 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -8,10 +8,10 @@ module Simplex.Chat.Wallet WalletError (..), newSeedEntropy, entropyFromMnemonic, + newWalletMaster, seedMnemonic, deriveAccount, accountSecret, - checkAccountIndex, ) where @@ -20,21 +20,18 @@ import Control.Monad.Except import Control.Monad.IO.Class (liftIO) import Crypto.Random (ChaChaDRG) import qualified Data.Aeson.TH as JQ -import Data.Bifunctor (bimap) -import qualified Data.ByteArray as BA +import Data.Bifunctor (first) import qualified Data.ByteArray.Encoding as BAE import Data.Text (Text) import Data.Text.Encoding (decodeLatin1) -import Data.Word (Word32) import qualified Simplex.Messaging.Crypto.BIP32 as B32 import qualified Simplex.Messaging.Crypto.BIP39 as B39 +import Simplex.Messaging.Crypto.BIP44 (AccountIndex, CoinType (..), bip44Path) import qualified Simplex.Messaging.Crypto.Secp256k1 as S import Simplex.Messaging.Encoding.String (strEncode) -import Simplex.Messaging.Eth.Address (addressFromPrivateKey, ethereumPath) +import Simplex.Messaging.Eth.Address (addressFromPrivateKey) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) -import Simplex.Messaging.Util (liftEitherWith, liftError') - -type AccountIndex = Word32 +import Simplex.Messaging.Util (liftError') type AccountKey = S.Secp256k1PrivateKey @@ -53,39 +50,34 @@ data WalletError | WEAccountBound | WEAccountNotHeld | WECounterUnknown - | WEIndexTooLarge + | WEAccountsExhausted | WEDerivation {derivationError :: String} deriving (Eq, Show) -checkAccountIndex :: AccountIndex -> Either WalletError () -checkAccountIndex n = () <$ accountPath n +masterStrength :: B39.EntropyStrength +masterStrength = B39.ES256 -accountPath :: AccountIndex -> Either WalletError [Word32] -accountPath n = maybe (Left WEIndexTooLarge) Right $ ethereumPath n 0 +newSeedEntropy :: TVar ChaChaDRG -> STM B39.WalletEntropy +newSeedEntropy = B39.randomEntropy masterStrength -masterStrength :: B39.MnemonicStrength -masterStrength = B39.MS256 - -newSeedEntropy :: TVar ChaChaDRG -> STM BA.ScrubbedBytes -newSeedEntropy g = B39.mnemonicToEntropy <$> B39.randomMnemonic masterStrength g - -entropyFromMnemonic :: Text -> Either WalletError BA.ScrubbedBytes -entropyFromMnemonic phrase = case B39.parseMnemonic phrase of - Right m | length (B39.mnemonicWords m) == B39.strengthWordCount masterStrength -> - Right $ B39.mnemonicToEntropy m +entropyFromMnemonic :: Text -> Either WalletError B39.WalletEntropy +entropyFromMnemonic phrase = case B39.parsePhrase phrase of + Right ent | B39.entropyWordCount ent == 24 -> Right ent _ -> Left WEBadMnemonic -seedMnemonic :: BA.ScrubbedBytes -> Either WalletError Text -seedMnemonic = bimap WEDerivation (decodeLatin1 . B39.mnemonicPhrase) . B39.entropyToMnemonic +newWalletMaster :: B39.WalletEntropy -> Either WalletError B32.WalletMaster +newWalletMaster ent = first WEDerivation $ B32.mkWalletMaster ent "" -deriveAccount :: TVar ChaChaDRG -> BA.ScrubbedBytes -> AccountIndex -> IO (Either WalletError (AccountKey, WalletAddress)) -deriveAccount g entropy n = runExceptT $ do - path <- liftEither $ accountPath n - m <- liftEitherWith WEDerivation $ B39.entropyToMnemonic entropy - master <- liftError' WEDerivation $ B32.masterKey g (B39.mnemonicToSeed m "") - k <- B32.xkKey <$> liftError' WEDerivation (B32.derivePath g master path) +seedMnemonic :: B32.WalletMaster -> Text +seedMnemonic = decodeLatin1 . B39.entropyPhrase . B32.masterEntropy + +deriveAccount :: TVar ChaChaDRG -> B32.WalletMaster -> AccountIndex -> IO (Either WalletError (AccountKey, WalletAddress)) +deriveAccount g master n = runExceptT $ do + k <- B32.xkKey <$> liftError' WEDerivation (B32.derivePath g (B32.walletMasterKey master) path) a <- liftIO $ addressFromPrivateKey g k pure (k, WalletAddress {accountIndex = n, keyPath = decodeLatin1 $ B32.renderPath path, address = decodeLatin1 $ strEncode a}) + where + path = bip44Path Ethereum n accountSecret :: AccountKey -> Text accountSecret k = "0x" <> decodeLatin1 (BAE.convertToBase BAE.Base16 $ S.unPrivateKey k) diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index dab11e43c1..3d96874c30 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -5,18 +5,21 @@ module WalletTests where import ChatClient import ChatTests.DBUtils import ChatTests.Utils -import Control.Monad (void) import qualified Data.ByteArray as BA import qualified Data.ByteArray.Encoding as BAE import qualified Data.ByteString.Char8 as B import Data.Char (toUpper) import Data.Either (isRight) import Data.List (nub) +import Data.Maybe (fromJust) import Data.Text (Text) import qualified Data.Text as T -import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress (..), WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, seedMnemonic) +import Data.Word (Word32) +import Simplex.Chat.Wallet (AccountKey, WalletAddress (..), WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, seedMnemonic) import qualified Simplex.Messaging.Crypto as C +import qualified Simplex.Messaging.Crypto.BIP32 as B32 import qualified Simplex.Messaging.Crypto.BIP39 as B39 +import Simplex.Messaging.Crypto.BIP44 (mkAccountIndex) import qualified Simplex.Messaging.Crypto.Secp256k1 as S import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Eth.Address (addressFromPrivateKey) @@ -29,18 +32,18 @@ testPhrase12 = T.unwords $ replicate 11 "abandon" <> ["about"] testPhrase24 :: Text testPhrase24 = T.unwords $ replicate 23 "abandon" <> ["art"] -seedEntropy :: Text -> BA.ScrubbedBytes -seedEntropy phrase = B39.mnemonicToEntropy . either error id $ B39.parseMnemonic phrase +walletMaster :: Text -> B32.WalletMaster +walletMaster phrase = either error id $ B32.mkWalletMaster (either error id $ B39.parsePhrase phrase) "" -walletAccount :: Text -> AccountIndex -> IO (AccountKey, WalletAddress) +walletAccount :: Text -> Word32 -> IO (AccountKey, WalletAddress) walletAccount phrase n = do g <- C.newRandom - either (error . show) id <$> deriveAccount g (seedEntropy phrase) n + either (error . show) id <$> deriveAccount g (walletMaster phrase) (fromJust $ mkAccountIndex n) addressFromSecret :: String -> IO String addressFromSecret secret = do g <- C.newRandom - k <- either error id <$> S.mkPrivateKey g (either error id $ BAE.convertFromBase BAE.Base16 (B.drop 2 $ B.pack secret)) + let k = either error id $ S.mkPrivateKey (either error id $ BAE.convertFromBase BAE.Base16 (B.drop 2 $ B.pack secret)) B.unpack . strEncode <$> addressFromPrivateKey g k exportRow :: HasCallStack => String -> (String, String, String, String) @@ -64,19 +67,15 @@ walletDerivationTests = do addrs <- mapM (fmap (address . snd) . walletAccount testPhrase12) [0 .. 9] length (nub addrs) `shouldBe` 10 Hspec.it "renders a secret whose first byte is zero with 64 hex digits" $ do - g <- C.newRandom - k <- either error id <$> S.mkPrivateKey g (BA.convert $ B.pack ('\0' : replicate 31 '\1')) + let k = either error id $ S.mkPrivateKey (BA.convert $ B.pack ('\0' : replicate 31 '\1')) let secret = T.unpack $ accountSecret k take 4 secret `shouldBe` "0x00" length secret `shouldBe` 66 Hspec.it "renders the path an account is derived at" $ do (keyPath . snd <$> walletAccount testPhrase12 0) `shouldReturn` "m/44'/60'/0'/0/0" (keyPath . snd <$> walletAccount testPhrase12 7) `shouldReturn` "m/44'/60'/7'/0/0" - Hspec.it "rejects an account index at or above 2^31" $ do - g <- C.newRandom - (void <$> deriveAccount g (seedEntropy testPhrase12) 2147483648) `shouldReturn` Left WEIndexTooLarge Hspec.it "round-trips the phrase it was imported from" $ - seedMnemonic (seedEntropy testPhrase24) `shouldBe` Right testPhrase24 + seedMnemonic (walletMaster testPhrase24) `shouldBe` testPhrase24 Hspec.it "accepts only 24 words with a valid checksum" $ do entropyFromMnemonic testPhrase24 `shouldSatisfy` isRight entropyFromMnemonic testPhrase12 `shouldBe` Left WEBadMnemonic @@ -304,11 +303,11 @@ testWalletIndexTooLarge ps = withNewTestChat ps "alice" aliceProfile $ \alice -> alice ##> "/_wallet create new" alice <## "wallet, no accounts for this profile" alice ##> "/_wallet address account=2147483648" - alice <## "wallet: account index must be below 2^31" + alice <## "bad chat command: Failed reading: empty" alice ##> "/_wallet bind 1 account=2147483648" - alice <## "wallet: account index must be below 2^31" + alice <## "bad chat command: Failed reading: empty" alice ##> "/_wallet export account 1 2147483648" - alice <## "wallet: account index must be below 2^31" + alice <## "bad chat command: Failed reading: empty" alice ##> "/_wallet address account=4294967296" alice <## "bad chat command: Failed reading: empty" alice ##> "/_wallet bind 1 account=4294967296" @@ -316,4 +315,4 @@ testWalletIndexTooLarge ps = withNewTestChat ps "alice" aliceProfile $ \alice -> alice ##> "/_wallet bind 1 account=2147483647" alice `accountBound` "2147483647" alice ##> "/_wallet bind 1" - alice <## "wallet: account index must be below 2^31" + alice <## "wallet: every account index is used" From 41d06dd4eab311533bc45d0453cb0d78e79873a5 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Sat, 26 Sep 2026 08:26:39 +0200 Subject: [PATCH 61/67] repin --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index 3829a02d35..df722f9005 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 2bfc408a58d4a0bed084816f9d885527c499e6a4 + tag: 88dd6c7041a49d6c15e3d83b49f581bbc3a9ac77 source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 78b783e70f..fd3bf524ad 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."2bfc408a58d4a0bed084816f9d885527c499e6a4" = "1ccq3qvhd2ml1dwzw26j9akhpnxdp7yjdsjpkg7vhglzmhyvzzd4"; + "https://github.com/simplex-chat/simplexmq.git"."88dd6c7041a49d6c15e3d83b49f581bbc3a9ac77" = "1bi0f34c3jb74n61r4inv52k8rc2pq75xxzk2wj7702xb4kdd9dn"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; From 143d0739808797b34b42d97bb99b3d152c3adcef Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Sat, 26 Sep 2026 12:34:06 +0200 Subject: [PATCH 62/67] infallibel wallet and account derivation --- bots/api/TYPES.md | 4 --- cabal.project | 2 +- docs/rfcs/2026-09-10-wallet-keys.md | 3 +-- .../types/typescript/src/types.ts | 7 ------ .../src/simplex_chat/types/_types.py | 7 +----- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Library/Commands.hs | 11 ++++---- src/Simplex/Chat/View.hs | 4 +-- src/Simplex/Chat/Wallet.hs | 25 ++++++++----------- tests/WalletTests.hs | 4 +-- 10 files changed, 23 insertions(+), 46 deletions(-) diff --git a/bots/api/TYPES.md b/bots/api/TYPES.md index d01fc3b4b4..7b7920690c 100644 --- a/bots/api/TYPES.md +++ b/bots/api/TYPES.md @@ -4668,10 +4668,6 @@ CounterUnknown: AccountsExhausted: - type: "accountsExhausted" -Derivation: -- type: "derivation" -- derivationError: string - --- diff --git a/cabal.project b/cabal.project index df722f9005..d30ed247cb 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 88dd6c7041a49d6c15e3d83b49f581bbc3a9ac77 + tag: 31360b40c4d3ede6baac78b00dec51df5812ba84 source-repository-package type: git diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index 4619c1229e..d11fdd44c7 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -85,7 +85,7 @@ A command that acts on a profile's accounts names the profile and is rejected wh `bind` without `account=` binds the account at a counter on the master, which is a high-water mark and not a count of bound accounts, and returns the bound account's index, path and address. With `account=` it binds that account, which is how an account found by a scan is attached to the profile it belongs to, and it is rejected for an account another profile holds. After an import the counter is unknown rather than zero, because the phrase does not encode how many accounts it has been used for, so binding the next account is rejected until a scan sets the counter, while binding a known account is still allowed. -BIP-32 marks an index as hardened by setting its top bit, so an index at or above 2^31 already has that bit set and derives the same key as the index 2^31 below it: account 2^31 is account 0. That is a collision, not a loss of hardening, and it would put one key under two account indexes. An account index is a simplexmq type that only holds values below 2^31, so the command parser rejects 2^31 and above as a bad command, the columns have CHECK constraints for that bound, and reading a row that violates it is an error. The counter's bound is one higher than an account's, because it contains the next index to bind, and 2^31 there means the counter has passed every index that can be hardened, which `bind` without an index reports. +BIP-32 marks an index as hardened by setting its top bit, so an index at or above 2^31 already has that bit set and derives the same key as the index 2^31 below it: account 2^31 is account 0. That is a collision, not a loss of hardening, and it would put one key under two account indexes. Derivation itself cannot fail: for the one key in 2^128 that BIP-32 declares invalid, simplexmq recomputes it as SLIP-0010 specifies and Trezor implements, instead of skipping the index, so every account index has a key. An account index is a simplexmq type that only holds values below 2^31, so the command parser rejects 2^31 and above as a bad command, the columns have CHECK constraints for that bound, and reading a row that violates it is an error. The counter's bound is one higher than an account's, because it contains the next index to bind, and 2^31 there means the counter has passed every index that can be hardened, which `bind` without an index reports. `address` reads the counter without changing it, so two calls return the same address, and it derives an address for an account the database has no row for, which a device that lost its database requires. One address per call is sufficient: a caller that scans the tree calls it in a loop. @@ -103,7 +103,6 @@ data WalletError | WEAccountNotHeld -- export account, on an account the profile does not hold | WECounterUnknown -- the counter is not set yet, after an import | WEAccountsExhausted -- bind without an index, when the counter has passed every index - | WEDerivation {derivationError :: String} -- BIP-32 or BIP-39 derivation failed ``` ## Recovery diff --git a/packages/simplex-chat-client/types/typescript/src/types.ts b/packages/simplex-chat-client/types/typescript/src/types.ts index 9cfc5f40c5..11bc46d7be 100644 --- a/packages/simplex-chat-client/types/typescript/src/types.ts +++ b/packages/simplex-chat-client/types/typescript/src/types.ts @@ -5316,7 +5316,6 @@ export type WalletError = | WalletError.AccountNotHeld | WalletError.CounterUnknown | WalletError.AccountsExhausted - | WalletError.Derivation export namespace WalletError { export type Tag = @@ -5328,7 +5327,6 @@ export namespace WalletError { | "accountNotHeld" | "counterUnknown" | "accountsExhausted" - | "derivation" interface Interface { type: Tag @@ -5365,11 +5363,6 @@ export namespace WalletError { export interface AccountsExhausted extends Interface { type: "accountsExhausted" } - - export interface Derivation extends Interface { - type: "derivation" - derivationError: string - } } export type XFTPErrorType = 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 a945f984a1..00983026fe 100644 --- a/packages/simplex-chat-python/src/simplex_chat/types/_types.py +++ b/packages/simplex-chat-python/src/simplex_chat/types/_types.py @@ -3746,10 +3746,6 @@ class WalletError_counterUnknown(TypedDict): class WalletError_accountsExhausted(TypedDict): type: Literal["accountsExhausted"] -class WalletError_derivation(TypedDict): - type: Literal["derivation"] - derivationError: str - WalletError = ( WalletError_noMaster | WalletError_masterExists @@ -3759,10 +3755,9 @@ WalletError = ( | WalletError_accountNotHeld | WalletError_counterUnknown | WalletError_accountsExhausted - | WalletError_derivation ) -WalletError_Tag = Literal["noMaster", "masterExists", "badMnemonic", "hiddenProfile", "accountBound", "accountNotHeld", "counterUnknown", "accountsExhausted", "derivation"] +WalletError_Tag = Literal["noMaster", "masterExists", "badMnemonic", "hiddenProfile", "accountBound", "accountNotHeld", "counterUnknown", "accountsExhausted"] class XFTPErrorType_BLOCK(TypedDict): type: Literal["BLOCK"] diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index fd3bf524ad..ffd091b489 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."88dd6c7041a49d6c15e3d83b49f581bbc3a9ac77" = "1bi0f34c3jb74n61r4inv52k8rc2pq75xxzk2wj7702xb4kdd9dn"; + "https://github.com/simplex-chat/simplexmq.git"."31360b40c4d3ede6baac78b00dec51df5812ba84" = "0srnp3lsc09r3y5jsy6f6snb5d678l6bxjpxk77dl0lqx2gx7d2j"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 46dc2173ec..5ddd65f259 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -65,7 +65,7 @@ import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeIss import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Badges.Service (BadgeBalance (..), BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), BadgeStatement (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..), currentBadgeServiceVersion) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) -import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress, WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, newSeedEntropy, newWalletMaster, seedMnemonic) +import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress, WalletError (..), accountSecret, deriveAccount, importWalletMaster, newWalletMaster, seedMnemonic) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -1506,10 +1506,9 @@ processChatCommand cxt nm = \case wallet_ <- withFastStore getWallet when (isJust wallet_) $ throwWalletError WEMasterExists -- the counter starts at 0 for a generated seed and is unknown for an imported one - (entropy, nextAccount) <- case mnemonic_ of - Nothing -> (,Just 0) <$> (asks random >>= atomically . newSeedEntropy) - Just phrase -> (,Nothing) <$> liftWallet (entropyFromMnemonic phrase) - master <- liftWallet $ newWalletMaster entropy + (master, nextAccount) <- case mnemonic_ of + Nothing -> (,Just 0) <$> (liftIO . newWalletMaster =<< asks random) + Just phrase -> (,Nothing) <$> liftWallet (importWalletMaster phrase) created <- withFastStore' $ \db -> createWallet db master nextAccount unless created $ throwWalletError WEMasterExists pure $ CRWallet user (Just []) @@ -6039,7 +6038,7 @@ withWalletStore action = liftWallet =<< withFastStore action walletAccount :: Wallet -> AccountIndex -> CM (AccountKey, WalletAddress) walletAccount Wallet {walletMaster} n = do g <- asks random - liftError' (ChatError . CEWallet) (deriveAccount g walletMaster n) + liftIO $ deriveAccount g walletMaster n chatCommandP :: Parser ChatCommand chatCommandP = diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index 5e50c4bf13..ae217b9dfc 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -2813,7 +2813,8 @@ viewChatError isCmd logLevel testView = \case SDEUnknownDomain -> "is not included in the connection link's profile" in [plain $ "SimpleX name " <> strEncode domain <> " " <> reason] CEWallet walletErr -> - let reason = case walletErr of + let reason :: Text + reason = case walletErr of WENoMaster -> "this device has no wallet" WEMasterExists -> "this device already has a wallet" WEBadMnemonic -> "not a valid 24 word recovery phrase" @@ -2822,7 +2823,6 @@ viewChatError isCmd logLevel testView = \case WEAccountNotHeld -> "this profile does not hold this account" WECounterUnknown -> "the next account is unknown after an import" WEAccountsExhausted -> "every account index is used" - WEDerivation e -> "derivation failed: " <> T.pack e in [plain $ "wallet: " <> reason] 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] diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index 0cfb909416..b5cfd922d2 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -6,9 +6,9 @@ module Simplex.Chat.Wallet AccountKey, WalletAddress (..), WalletError (..), - newSeedEntropy, - entropyFromMnemonic, newWalletMaster, + entropyFromMnemonic, + importWalletMaster, seedMnemonic, deriveAccount, accountSecret, @@ -16,11 +16,8 @@ module Simplex.Chat.Wallet where import Control.Concurrent.STM -import Control.Monad.Except -import Control.Monad.IO.Class (liftIO) import Crypto.Random (ChaChaDRG) import qualified Data.Aeson.TH as JQ -import Data.Bifunctor (first) import qualified Data.ByteArray.Encoding as BAE import Data.Text (Text) import Data.Text.Encoding (decodeLatin1) @@ -31,7 +28,6 @@ import qualified Simplex.Messaging.Crypto.Secp256k1 as S import Simplex.Messaging.Encoding.String (strEncode) import Simplex.Messaging.Eth.Address (addressFromPrivateKey) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) -import Simplex.Messaging.Util (liftError') type AccountKey = S.Secp256k1PrivateKey @@ -51,30 +47,29 @@ data WalletError | WEAccountNotHeld | WECounterUnknown | WEAccountsExhausted - | WEDerivation {derivationError :: String} deriving (Eq, Show) masterStrength :: B39.EntropyStrength masterStrength = B39.ES256 -newSeedEntropy :: TVar ChaChaDRG -> STM B39.WalletEntropy -newSeedEntropy = B39.randomEntropy masterStrength +newWalletMaster :: TVar ChaChaDRG -> IO B32.WalletMaster +newWalletMaster g = (`B32.mkWalletMaster` "") <$> atomically (B39.randomEntropy masterStrength g) entropyFromMnemonic :: Text -> Either WalletError B39.WalletEntropy entropyFromMnemonic phrase = case B39.parsePhrase phrase of Right ent | B39.entropyWordCount ent == 24 -> Right ent _ -> Left WEBadMnemonic -newWalletMaster :: B39.WalletEntropy -> Either WalletError B32.WalletMaster -newWalletMaster ent = first WEDerivation $ B32.mkWalletMaster ent "" +importWalletMaster :: Text -> Either WalletError B32.WalletMaster +importWalletMaster phrase = (`B32.mkWalletMaster` "") <$> entropyFromMnemonic phrase seedMnemonic :: B32.WalletMaster -> Text seedMnemonic = decodeLatin1 . B39.entropyPhrase . B32.masterEntropy -deriveAccount :: TVar ChaChaDRG -> B32.WalletMaster -> AccountIndex -> IO (Either WalletError (AccountKey, WalletAddress)) -deriveAccount g master n = runExceptT $ do - k <- B32.xkKey <$> liftError' WEDerivation (B32.derivePath g (B32.walletMasterKey master) path) - a <- liftIO $ addressFromPrivateKey g k +deriveAccount :: TVar ChaChaDRG -> B32.WalletMaster -> AccountIndex -> IO (AccountKey, WalletAddress) +deriveAccount g master n = do + k <- B32.xkKey <$> B32.derivePath g (B32.walletMasterKey master) path + a <- addressFromPrivateKey g k pure (k, WalletAddress {accountIndex = n, keyPath = decodeLatin1 $ B32.renderPath path, address = decodeLatin1 $ strEncode a}) where path = bip44Path Ethereum n diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index 3d96874c30..f013af1102 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -33,12 +33,12 @@ testPhrase24 :: Text testPhrase24 = T.unwords $ replicate 23 "abandon" <> ["art"] walletMaster :: Text -> B32.WalletMaster -walletMaster phrase = either error id $ B32.mkWalletMaster (either error id $ B39.parsePhrase phrase) "" +walletMaster phrase = B32.mkWalletMaster (either error id $ B39.parsePhrase phrase) "" walletAccount :: Text -> Word32 -> IO (AccountKey, WalletAddress) walletAccount phrase n = do g <- C.newRandom - either (error . show) id <$> deriveAccount g (walletMaster phrase) (fromJust $ mkAccountIndex n) + deriveAccount g (walletMaster phrase) (fromJust $ mkAccountIndex n) addressFromSecret :: String -> IO String addressFromSecret secret = do From 7b4ac1bb8b8c8d304c64e8fef34ad2e85ddf94fe Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Sat, 26 Sep 2026 14:29:40 +0200 Subject: [PATCH 63/67] review iterations --- cabal.project | 2 +- docs/rfcs/2026-09-10-wallet-keys.md | 36 +++---- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Controller.hs | 5 +- src/Simplex/Chat/Library/Commands.hs | 43 +++++---- .../Migrations/M20260924_wallet_seeds.hs | 2 +- .../Store/Postgres/Migrations/chat_schema.sql | 2 +- .../Migrations/M20260924_wallet_seeds.hs | 2 +- .../Store/SQLite/Migrations/chat_schema.sql | 2 +- src/Simplex/Chat/Store/Wallets.hs | 55 ++++++----- src/Simplex/Chat/View.hs | 14 +-- src/Simplex/Chat/Wallet.hs | 35 ++++--- tests/WalletTests.hs | 95 ++++++++++--------- 13 files changed, 161 insertions(+), 134 deletions(-) diff --git a/cabal.project b/cabal.project index d30ed247cb..4df24c38cb 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 31360b40c4d3ede6baac78b00dec51df5812ba84 + tag: 360a82cf7d461a34b9a1709f2b2c60c5cf7188ce source-repository-package type: git diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index d11fdd44c7..f44ff94357 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -13,7 +13,7 @@ Out of scope of this doc: buying a name, the names protocol, the registrar, sign 1. Every key the device uses is derivable again from the master phrase alone. 2. Giving away one account key gives away that account and nothing else, whatever else the recipient has. 3. No extended public key links two accounts. -4. Key material is generated or derived only by an explicit command, never at startup and never as a side effect of a read. +4. Key material is generated or imported only by an explicit command, never at startup; reading the wallet row re-derives the master key to check it, and account keys are derived only by the command that uses them. 5. A hidden profile owns nothing on chain, so nothing on chain is linked to it. The master phrase derives every account and an account secret controls one account; no export covers anything in between, such as "this profile's accounts", because the profile is not an input to the derivation. @@ -27,11 +27,11 @@ A device has one piece of [BIP-39](https://github.com/bitcoin/bips/blob/master/b Each thing the device owns on chain is assigned an **account index**, and the key at that index is the **account key**. Names are the first use: one name, one account, and that account's address is the owner address in the name record. Which chat profile an account belongs to is a mapping in the database. No path contains a profile, so no profile data is an input to the derivation, and the mapping can be changed without changing any key. ``` -master seed, 24 words the only key material to back up +master seed the only key material to back up └── m/44'/60'/n'/0/0 account n, n >= 0 ``` -An account index is BIP-44's own account level, the level [Ledger Live](https://github.com/LedgerHQ/ledger-live-common/blob/HEAD/docs/derivation.md) varies and also calls an account, numbering from one where its Account 1 is index 0. So the master phrase imported into another wallet derives the same addresses, and the two tools use the word for the same thing. That wallet discovers account 0 and stops, because BIP-44 specifies that discovery stops at the first account with no transaction history and an account that only owns a name has none; above it the user has to enter the path. +An account index is BIP-44's own account level, the level [Ledger Live](https://github.com/LedgerHQ/ledger-live-common/blob/HEAD/docs/derivation.md) varies and also calls an account, numbering from one where its Account 1 is index 0. So the master phrase imported into another wallet derives the same addresses, and the two tools use the word for the same thing. That wallet discovers account 0 and stops, because BIP-44 specifies that discovery stops at the first account with no transaction history, and this app leaves account 0 empty (known limit 10) and an account that only owns a name has none; above it the user has to enter the path in a wallet that accepts one, such as MEW, Rabby or Frame, since Ledger Live, MetaMask and Trezor Suite do not, which leaves the exported account key for those. The tests pin the derivation against the standard `abandon ... about` test mnemonic, which is 12 words, with an empty BIP-39 passphrase. Addresses are in [EIP-55](https://eips.ethereum.org/EIPS/eip-55) mixed case. @@ -46,29 +46,29 @@ The alternative is one account owning several names. A name's owner address is p ### Why the account level is hardened -The alternative is BIP-44's ordinary address level, `m/44'/60'/0'/0/n`, which is what MetaMask enumerates and is therefore the friendlier path. It is not hardened, and [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) has a known weakness there: the extended public key of a parent, together with one non-hardened child's private key, yields the parent private key and from it every sibling. An exported account key is one half, and any wallet that enumerates accounts produces the other. The two levels below an account are not hardened, so the two halves together reveal the extended private key at the account level, m/44'/60'/n'; nothing else is derived under an account and the account level itself is hardened, so they reveal no other account's key. This satisfies objectives 2 and 3, and it is worth the loss of MetaMask's default path. +The alternative is BIP-44's ordinary address level, `m/44'/60'/0'/0/n`, which is what MetaMask enumerates and is therefore the friendlier path. It is not hardened, and [BIP-32](https://github.com/bitcoin/bips/blob/master/bip-0032.mediawiki) has a known weakness there: the extended public key of a parent, together with one non-hardened child's private key, yields the parent private key and from it every sibling. An exported account key is one half, and any wallet that enumerates accounts produces the other. The two levels below an account are not hardened, so the two halves together reveal the extended private key at the account level, m/44'/60'/n'; nothing else is derived under an account by this app and the account level itself is hardened, so they reveal no other account's key; known limit 10 covers account 0, under which other wallets do derive. This satisfies objectives 2 and 3, and it is worth the loss of MetaMask's default path. ### Why 24 words -Entropy cannot be added to a seed afterwards and the uses of a seed can grow, so the master is 24 words, 256 bits. +Entropy cannot be added to a seed afterwards and the uses of a seed can grow, so a generated master is 24 words, 256 bits. An imported phrase is accepted at any BIP-39 length, 12 to 24 words, because its strength was decided by the wallet that generated it and other wallets default to 12. ## Profiles and accounts An account is bound to at most one chat profile, and a profile to any number of accounts, because a profile can own any number of names. Only one of those names points at the profile as its SimpleX domain name, but which one is recorded by the names layer, not by the wallet: the wallet records only which profile an account belongs to. -Accounts are allocated in order and never reused, because an account the device no longer tracks still owns whatever it holds. An account can remain unbound. Nothing is bound when a profile is created; an account is bound on first use, when the user buys a name for a profile, so a device on which the user neither buys anything nor requests an address has never derived a key. +Accounts are allocated in order and never reused, because an account the device no longer tracks still owns whatever it holds. An account can remain unbound. Nothing is bound when a profile is created; an account is bound on first use, when the user buys a name for a profile, so a device on which the user neither buys anything nor requests an address has never derived an account key. An account cannot be bound to a hidden profile, so no name can be bought for a hidden profile. Two things would leak: the master derives every account, so whoever can use any profile can also derive a hidden profile's account keys; and a name is written into the profile's own database row and listed across the device, while a hidden profile is a filter on what is shown, not encryption. Closing either requires changes in the profiles and in the name record, not in the key layout. Incognito is a property of a connection in this app rather than of a profile, so there is nothing at this level to reject; the random profile of an incognito connection is not a chat profile, so it cannot hold an account. ## Commands -These commands are an internal API, called by the names commands and by any later feature that binds accounts, rather than typed by users. Nothing here runs at startup or as a side effect of a read. +These commands are an internal API, called by the names commands and by any later feature that binds accounts, rather than typed by users. Nothing here runs at startup. ``` -/_wallet whether the device has a master, and the - accounts bound to the profile +/_wallet whether the device has a master, the next + account, and the accounts bound to the profile /_wallet create new generate the master, 24 words -/_wallet create mnemonic= import the master entropy from a phrase, 24 words +/_wallet create mnemonic= import the master entropy from a phrase, 12 to 24 words /_wallet bind account= bind an account to the profile and return its address; without account=, the next free one /_wallet address account= one address, with the account index it is @@ -81,7 +81,7 @@ These commands are an internal API, called by the names commands and by any late A command that acts on a profile's accounts names the profile and is rejected when that profile is not the active one, so switching profiles during a flow cannot bind an account to the wrong profile. -`create` always names its source, so no key material is generated by typing a prefix. An imported phrase must be 24 BIP-39 English words with a valid checksum, normalised for case and spacing. There is no BIP-39 passphrase, because it would be a second secret to back up and losing it would look exactly like losing the phrase, so a phrase used with a passphrase elsewhere derives different keys here. +`create` always names its source, so no key material is generated by typing a prefix. An imported phrase must be 12, 15, 18, 21 or 24 BIP-39 English words with a valid checksum, normalised for case and spacing. There is no BIP-39 passphrase, because it would be a second secret to back up and losing it would look exactly like losing the phrase, so a phrase used with a passphrase elsewhere derives different keys here. `bind` without `account=` binds the account at a counter on the master, which is a high-water mark and not a count of bound accounts, and returns the bound account's index, path and address. With `account=` it binds that account, which is how an account found by a scan is attached to the profile it belongs to, and it is rejected for an account another profile holds. After an import the counter is unknown rather than zero, because the phrase does not encode how many accounts it has been used for, so binding the next account is rejected until a scan sets the counter, while binding a known account is still allowed. @@ -92,7 +92,8 @@ BIP-32 marks an index as hardened by setting its top bit, so an index at or abov `export account` is rejected unless the profile holds the account, so every exported key belongs to an account that is already bound, which `bind` without an index does not return, because the counter is past it or unknown; the exception is a database restored from a backup (known limit 9). An export is a copy and not a transfer: the device still derives what it exported and can still sign with it, so giving an account key away leaves two parties able to act as its owner until whatever it holds is transferred on chain. Signing is not in this change, and when it is added it is a command here that signs and returns a signature, not `export account` followed by signing elsewhere, which would make the narrow export the ordinary path. `delete` leaves whatever the accounts own on chain, recoverable only from the phrase. ```haskell -data WalletAddress = WalletAddress {accountIndex :: Word32, keyPath :: Text, address :: Text} +data WalletAddress = WalletAddress {accountIndex :: AccountIndex, keyPath :: Text, address :: Address} +data WalletInfo = WalletInfo {accountIndexes :: [AccountIndex], nextAccountIndex :: Maybe Word32} -- Nothing: unknown after an import data WalletError = WENoMaster -- the device has no master entropy @@ -122,7 +123,7 @@ What the scan finds is unbound, and the user attaches each account to a profile ```sql CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, - entropy BLOB NOT NULL CHECK (length(entropy) = 32), + entropy BLOB NOT NULL CHECK (length(entropy) IN (16, 20, 24, 28, 32)), master BLOB NOT NULL CHECK (length(master) = 64), next_account_index INTEGER CHECK (next_account_index BETWEEN 0 AND 2147483648), -- null means not known yet single_seed INTEGER NOT NULL DEFAULT 1 @@ -140,7 +141,7 @@ CREATE UNIQUE INDEX idx_wallet_accounts_wallet_seed_id_account_index ON wallet_a CREATE INDEX idx_wallet_accounts_user_id ON wallet_accounts(user_id); ``` -The master entropy, always 32 bytes since it is generated and imported as 24 words, is stored with the BIP-32 master key it derives, the 32-byte private key followed by the 32-byte chain code. Reading the row recomputes the master key from the entropy and rejects a row where the two do not match, so a wallet read from the database always derives accounts. An account key is never stored, because the master key and an account index derive it whenever it is required. So `wallet_accounts` contains what derivation cannot produce: which account indexes are recorded on the device and which profile each belongs to. A row with no `user_id` is an account no profile holds, which is the result of deleting a chat profile and what a scan writes. +The master entropy, 32 bytes when generated and 16 to 32 bytes when imported, is stored with the BIP-32 master key it derives, the 32-byte private key followed by the 32-byte chain code. Reading the row recomputes the master key from the entropy and rejects a row where the two do not match, so a wallet read from the database always derives accounts. An account key is never stored, because the master key and an account index derive it whenever it is required. So `wallet_accounts` contains what derivation cannot produce: which account indexes are recorded on the device and which profile each belongs to. A row with no `user_id` is an account no profile holds, which is the result of deleting a chat profile and what a scan writes. `users` is not changed: the mapping is stored in the account row, and the index on `user_id` is not unique, because a profile can hold any number of accounts. One seed per device is enforced by `single_seed` and the unique index on it, which a later change removes with a `DROP INDEX` and a `DROP COLUMN`; it is a named index rather than an inline `UNIQUE` because SQLite cannot drop an inline constraint without rebuilding the table. Deleting the master deletes its account rows, because an account index without its entropy derives nothing. The migration has no down migration, because a down migration would delete the master entropy, which may have no other copy. A down migration runs when an older app opens a newer database and the user confirms "Downgrade and open chat", and the backup made then is overwritten by the next upgrade. Without a down migration the older app reports that the database is newer than the app, and changes nothing. @@ -153,7 +154,7 @@ A null `account_index` marks an account whose key was imported rather than deriv - **A wallet the master phrase is imported into.** Enumerating BIP-44 accounts computes account extended public keys, and some wallets send them to a vendor, which gives that vendor every account on the device at once, across every profile. That is what an account for each name otherwise prevents. - **Whoever answers the recovery scan.** Receives every address the scan derives from the phrase, in one sequence of requests, so it can link every account on the device, across profiles, and recognise addresses that own nothing yet, which is where future accounts will be. `address` derives an address for any index directly from the master, so a caller can enumerate hidden profiles' addresses too. This is the largest privacy cost of the design. - **A paired device.** Wallet commands are allowed from a paired device like other chat commands: `export master` returns the whole wallet, `create mnemonic=` on a device that has no seed imports a phrase that the paired device sends, and `delete` deletes the master entropy, which may have no other copy. -- **Someone reading the logs.** The core logs no command and no response, so a phrase passed to `create` is not written to any log. +- **Someone reading the logs.** The core logs no command and no response, so a phrase passed to `create` is not written to any log. On Postgres the client inlines parameters into the statement text, so server-side statement logging records the inserted entropy and master key. - **A page open in the user's browser.** The websocket server in `apps/simplex-chat/Server.hs`, which runs only with `--chat-server-port` and listens on 127.0.0.1, accepts any local connection, requires no token and checks no `Origin`, and websockets are not bound by the same origin policy, so any page loaded while that server runs can send `export master` and read the response. It also prints every command it receives, that phrase included. Both are properties of that server, which this change makes a more valuable target, and closing them requires changes to that server rather than to the wallet. ## Known limits @@ -164,9 +165,10 @@ A null `account_index` marks an account whose key was imported rather than deriv 4. **The same phrase on two devices collides.** The counter is stored in one database, so both devices bind the same account and each treats it as free. Sharing the counter requires a backup both devices can read. 5. **A profile hidden after an account was bound to it keeps its accounts.** The check runs at bind time only. 6. **Account indexes are not dense.** An account can be bound and never used, and a run of empty accounts ends the scan, so an account after a gap can be missed. -7. **Gas payments and discovery conflict.** If an account ever pays for anything, whatever funds it links accounts on chain. If it never pays, no wallet discovers it past account 0. +7. **Other wallets do not discover the accounts.** A generated wallet leaves account 0 empty, so BIP-44 discovery in Ledger Live or Trezor Suite stops before the first account this app uses, whether or not later accounts hold funds or paid gas; they are reached only by entering the path or with the exported key. If an account ever pays for anything, whatever funds it links accounts on chain. 8. **Nothing records which layout a seed was used with.** Another wallet may have derived accounts from the phrase at paths this doc does not describe. 9. **`bind` on a restored database can return an account that is already in use.** Its counter can be below the highest index used, and nothing detects that, so a name can be bought with an account that already owns one until the scan resets the counter. +10. **Account 0 is the primary account of other wallets.** MetaMask, Ledger Live and Trezor Suite present `m/44'/60'/0'/0/0` first, and their hardware integrations hand a browser the extended public key of its address level, so with a phrase used in one of them the export of account 0 exposes that wallet's accounts. A generated wallet therefore starts its counter at 1, and account 0 is bound or exported only when asked for with `account=0`. ## Main files @@ -183,6 +185,6 @@ A null `account_index` marks an account whose key was imported rather than deriv 1. **Vectors.** The two addresses above are derived from `abandon ... about`, as is account 0's secret, pinned to the value another wallet shows for it. A 24 word phrase imported through the command derives a pinned address end to end, so a change of path fails here rather than in a release, and the account a command names is the account whose key is returned. 2. **Isolation.** Ten accounts' addresses are all different, and the paths of accounts 0 and 7 have a hardened account component. -3. **Rejections.** A second generate; a phrase that is not 24 valid words; `bind`, `delete` and `export master` on a device with no wallet; `bind` on a hidden profile, on an account another profile holds, and on an imported master whose counter is unknown; `export account` for an account the profile does not hold; `bind` without an index once the counter has passed 2^31 - 1; an index of 2^31 or more is rejected as a bad command on `address`, `bind` and `export account` alike. +3. **Rejections.** A second generate; a phrase with a bad checksum; `bind`, `delete` and `export master` on a device with no wallet; `bind` on a hidden profile, on an account another profile holds, and on an imported master whose counter is unknown; `export account` for an account the profile does not hold; `bind` without an index once the counter has passed 2^31 - 1; an index of 2^31 or more is rejected as a bad command on `address`, `bind` and `export account` alike. 4. **Binding and reads.** Several accounts are bound to one profile, each `bind` returns the account it bound, and that profile's accounts can be exported; binding an account by index moves the counter past it so the next one does not collide, and never moves it back; `bind account=` binds an account after an import; an account left unbound by deleting its profile is bound to another profile; and two consecutive `address` calls return the same address without changing the counter, and derive an address for an account with no row. 5. **Encoding and persistence.** An account secret whose first byte is zero is rendered with 64 hex digits; the wallet, its accounts, the counter and the phrase persist across a restart; and deleting the wallet deletes its accounts and resets the counter. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index ffd091b489..9d74dfe64b 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."31360b40c4d3ede6baac78b00dec51df5812ba84" = "0srnp3lsc09r3y5jsy6f6snb5d678l6bxjpxk77dl0lqx2gx7d2j"; + "https://github.com/simplex-chat/simplexmq.git"."360a82cf7d461a34b9a1709f2b2c60c5cf7188ce" = "1hq5j8yx3kj1iwxj1pmjixxac40rwkh9mizkfpcf1iyprjsmbi6y"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index 6d4aa098a2..82b7308ea0 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -69,8 +69,9 @@ import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared import Simplex.Chat.Types.UITheme import Simplex.Chat.Util (liftIOEither) -import Simplex.Chat.Wallet (AccountIndex, WalletAddress, WalletError) +import Simplex.Chat.Wallet (WalletAddress, WalletError, WalletInfo) import Simplex.FileTransfer.Description (FileDescriptionURI) +import Simplex.Messaging.Crypto.BIP44 (AccountIndex) import Simplex.Messaging.Server.Information (ServerPublicInfo) import Simplex.Messaging.Agent (AgentClient, DatabaseDiff, SubscriptionsInfo) import Simplex.Messaging.Agent.Client (AgentLocks, AgentQueuesInfo (..), AgentWorkersDetails (..), AgentWorkersSummary (..), ProtocolTestFailure, SMPServerSubs, ServerQueueInfo, UserNetworkInfo) @@ -879,7 +880,7 @@ data ChatResponse | CRBadgeRedeemed {user :: User, redeemedBadge :: LocalBadge, newBadge :: Bool, badgeState :: Maybe BadgeState} | CRBadgeState {user :: User, badgeState :: Maybe BadgeState} | CRBadgeLedger {user :: User, badgeLedger :: [StatementEntry]} - | CRWallet {user :: User, accountIndexes_ :: Maybe [AccountIndex]} + | CRWallet {user :: User, walletInfo_ :: Maybe WalletInfo} | CRWalletMnemonic {user :: User, mnemonic :: Text} | CRWalletAddress {user :: User, walletAddress :: WalletAddress} | CRWalletAccountSecret {user :: User, walletAddress :: WalletAddress, secret :: Text} diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 5ddd65f259..b23aa20feb 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -65,7 +65,7 @@ import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeIss import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Badges.Service (BadgeBalance (..), BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), BadgeStatement (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..), currentBadgeServiceVersion) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) -import Simplex.Chat.Wallet (AccountIndex, AccountKey, WalletAddress, WalletError (..), accountSecret, deriveAccount, importWalletMaster, newWalletMaster, seedMnemonic) +import Simplex.Chat.Wallet (AccountKey, WalletAddress, WalletError (..), WalletInfo (..), accountSecret, deriveAccount, importWalletMaster, masterMnemonic, newWalletMaster) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -96,7 +96,7 @@ import Simplex.Chat.Store.Messages import Simplex.Chat.Store.NoteFolders import Simplex.Chat.Store.Profiles import Simplex.Chat.Store.Shared -import Simplex.Chat.Store.Wallets (Wallet (..), accountHeldBy, bindAccount, createWallet, deleteWallet, getUserAccounts, getWallet, resolveAccount) +import Simplex.Chat.Store.Wallets (Wallet (..), bindAccount, createWallet, deleteWallet, getUserAccounts, getWallet, heldAccount, resolveAccount) import Simplex.Chat.Types import Simplex.Chat.Types.Preferences import Simplex.Chat.Types.Shared @@ -116,7 +116,8 @@ import Simplex.Messaging.Agent.Store.Interface (getCurrentMigrations) import Simplex.Messaging.Client (NetworkConfig (..), NetworkRequestMode (..), NetworkTimeout (..), SMPWebPortServers (..), SocksMode (SMAlways), pattern NRMInteractive, textToHostMode) import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.ShortLink as SL -import Simplex.Messaging.Crypto.BIP44 (mkAccountIndex) +import Simplex.Messaging.Crypto.BIP32 (WalletMaster) +import Simplex.Messaging.Crypto.BIP44 (AccountIndex, mkAccountIndex) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF import Simplex.Messaging.Crypto.Ratchet (E2ERatchetParamsUri (..), InitialKeys (..), PQEncryption (..), PQSupport (..), pattern IKPQOff, pattern IKPQOn, pattern PQSupportOff, pattern PQSupportOn) @@ -1501,32 +1502,30 @@ processChatCommand cxt nm = \case connId <- withAgent $ \a -> sendServiceReplyAsync a "" (aUserId user) invId (LB.toStrict $ J.encode responseData) pure $ CRServiceReplyAccepted user (AgentConnId connId) APIGetWallet userId -> withUserId userId $ \user -> - CRWallet user <$> withFastStore (\db -> getWallet db >>= mapM (\Wallet {walletId} -> liftIO $ getUserAccounts db walletId userId)) + CRWallet user <$> withFastStore (\db -> getWallet db >>= mapM (\Wallet {walletId, nextAccountIndex} -> liftIO $ (`WalletInfo` nextAccountIndex) <$> getUserAccounts db userId walletId)) APICreateWallet mnemonic_ -> withUser $ \user -> do wallet_ <- withFastStore getWallet when (isJust wallet_) $ throwWalletError WEMasterExists - -- the counter starts at 0 for a generated seed and is unknown for an imported one + -- the counter starts at 1 for a generated seed, leaving account 0 to other wallets, and is unknown for an imported one (master, nextAccount) <- case mnemonic_ of - Nothing -> (,Just 0) <$> (liftIO . newWalletMaster =<< asks random) + Nothing -> (,Just 1) <$> (liftIO . newWalletMaster =<< asks random) Just phrase -> (,Nothing) <$> liftWallet (importWalletMaster phrase) created <- withFastStore' $ \db -> createWallet db master nextAccount unless created $ throwWalletError WEMasterExists - pure $ CRWallet user (Just []) + pure $ CRWallet user (Just $ WalletInfo [] nextAccount) APIBindWalletAccount userId accountIdx_ -> withUserId userId $ \user@User {viewPwdHash} -> do when (isJust viewPwdHash) $ throwWalletError WEHiddenProfile - (wallet, n) <- withWalletStore $ \db -> bindAccount db userId accountIdx_ - CRWalletAddress user . snd <$> walletAccount wallet n + (master, n) <- withWalletStore $ \db -> bindAccount db userId accountIdx_ + CRWalletAddress user . snd <$> walletAccount master n APIGetWalletAddress accountIdx_ -> withUser $ \user -> do - (wallet, n) <- withWalletStore (`resolveAccount` accountIdx_) - CRWalletAddress user . snd <$> walletAccount wallet n + (master, n) <- withWalletStore (`resolveAccount` accountIdx_) + CRWalletAddress user . snd <$> walletAccount master n APIExportWalletMnemonic -> withUser $ \user -> do Wallet {walletMaster} <- withFastStore getWallet >>= maybe (throwWalletError WENoMaster) pure - pure $ CRWalletMnemonic user (seedMnemonic walletMaster) + pure $ CRWalletMnemonic user (masterMnemonic walletMaster) APIExportWalletAccount userId n -> withUserId userId $ \user -> do - (wallet@Wallet {walletId}, _) <- withWalletStore (`resolveAccount` Just n) - held <- withFastStore' $ \db -> accountHeldBy db walletId userId n - unless held $ throwWalletError WEAccountNotHeld - (k, a) <- walletAccount wallet n + master <- withWalletStore $ \db -> heldAccount db userId n + (k, a) <- walletAccount master n pure $ CRWalletAccountSecret user a (accountSecret k) APIDeleteWallet -> withUser_ $ do deleted <- withFastStore' deleteWallet @@ -6035,10 +6034,10 @@ liftWallet = liftEitherWith (ChatError . CEWallet) withWalletStore :: (DB.Connection -> ExceptT StoreError IO (Either WalletError a)) -> CM a withWalletStore action = liftWallet =<< withFastStore action -walletAccount :: Wallet -> AccountIndex -> CM (AccountKey, WalletAddress) -walletAccount Wallet {walletMaster} n = do +walletAccount :: WalletMaster -> AccountIndex -> CM (AccountKey, WalletAddress) +walletAccount master n = do g <- asks random - liftIO $ deriveAccount g walletMaster n + liftIO $ deriveAccount g master n chatCommandP :: Parser ChatCommand chatCommandP = @@ -6714,8 +6713,12 @@ chatCommandP = text1P = safeDecodeUtf8 <$> A.takeTill (== ' ') char_ = optional . A.char accountIndexP = do + ds <- A.lookAhead $ A.takeWhile1 isDigit + when (B.length (B.dropWhile (== '0') ds) > 10) tooLarge i <- A.decimal - maybe (fail "account index too large") pure $ if i <= toInteger (maxBound :: Word32) then mkAccountIndex (fromInteger i) else Nothing + if i <= toInteger (maxBound :: Word32) then either fail pure $ mkAccountIndex (fromInteger i) else tooLarge + where + tooLarge = fail "account index too large" displayNameP :: Parser Text displayNameP = safeDecodeUtf8 <$> displayNameP_ diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs b/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs index eded0fea16..9cff8f3d9c 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/Postgres/Migrations/M20260924_wallet_seeds.hs @@ -11,7 +11,7 @@ m20260924_wallet_seeds = [r| CREATE TABLE wallet_seeds ( wallet_seed_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY, - entropy BYTEA NOT NULL CHECK (length(entropy) = 32), + entropy BYTEA NOT NULL CHECK (length(entropy) IN (16, 20, 24, 28, 32)), master BYTEA NOT NULL CHECK (length(master) = 64), next_account_index BIGINT CHECK (next_account_index BETWEEN 0 AND 2147483648), single_seed SMALLINT NOT NULL DEFAULT 1 diff --git a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql index 1b32e179e5..b31b19b1af 100644 --- a/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/Postgres/Migrations/chat_schema.sql @@ -1757,7 +1757,7 @@ CREATE TABLE test_chat_schema.wallet_seeds ( master bytea NOT NULL, next_account_index bigint, single_seed smallint DEFAULT 1 NOT NULL, - CONSTRAINT wallet_seeds_entropy_check CHECK ((length(entropy) = 32)), + CONSTRAINT wallet_seeds_entropy_check CHECK ((length(entropy) = ANY (ARRAY[16, 20, 24, 28, 32]))), CONSTRAINT wallet_seeds_master_check CHECK ((length(master) = 64)), CONSTRAINT wallet_seeds_next_account_index_check CHECK (((next_account_index >= 0) AND (next_account_index <= '2147483648'::bigint))) ); diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs b/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs index fb9f95b429..bb389633bd 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs +++ b/src/Simplex/Chat/Store/SQLite/Migrations/M20260924_wallet_seeds.hs @@ -10,7 +10,7 @@ m20260924_wallet_seeds = [sql| CREATE TABLE wallet_seeds ( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, - entropy BLOB NOT NULL CHECK (length(entropy) = 32), + entropy BLOB NOT NULL CHECK (length(entropy) IN (16, 20, 24, 28, 32)), master BLOB NOT NULL CHECK (length(master) = 64), next_account_index INTEGER CHECK (next_account_index BETWEEN 0 AND 2147483648), single_seed INTEGER NOT NULL DEFAULT 1 diff --git a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql index a4d619067b..b3f1c1a4f8 100644 --- a/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql +++ b/src/Simplex/Chat/Store/SQLite/Migrations/chat_schema.sql @@ -983,7 +983,7 @@ CREATE TABLE badge_code_redemptions( ) STRICT; CREATE TABLE wallet_seeds( wallet_seed_id INTEGER PRIMARY KEY AUTOINCREMENT, - entropy BLOB NOT NULL CHECK(length(entropy) = 32), + entropy BLOB NOT NULL CHECK(length(entropy) IN(16, 20, 24, 28, 32)), master BLOB NOT NULL CHECK(length(master) = 64), next_account_index INTEGER CHECK(next_account_index BETWEEN 0 AND 2147483648), single_seed INTEGER NOT NULL DEFAULT 1 diff --git a/src/Simplex/Chat/Store/Wallets.hs b/src/Simplex/Chat/Store/Wallets.hs index 95a8b839da..074cfc0e8d 100644 --- a/src/Simplex/Chat/Store/Wallets.hs +++ b/src/Simplex/Chat/Store/Wallets.hs @@ -13,25 +13,26 @@ module Simplex.Chat.Store.Wallets deleteWallet, resolveAccount, getUserAccounts, - accountHeldBy, + heldAccount, bindAccount, ) where import Control.Monad.Except +import Data.Bifunctor (first) import Control.Monad.IO.Class (liftIO) import qualified Data.ByteArray as BA import Data.ByteString (ByteString) import Data.Int (Int64) import Data.Word (Word32) import Simplex.Chat.Store.Shared (StoreError (..)) -import Simplex.Chat.Wallet (AccountIndex, WalletError (..)) +import Simplex.Chat.Wallet (WalletError (..)) import Simplex.Messaging.Agent.Protocol (UserId) import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow) import qualified Simplex.Messaging.Agent.Store.DB as DB import Simplex.Messaging.Crypto.BIP32 (WalletMaster, masterBytes, masterEntropy, parseWalletMaster) import Simplex.Messaging.Crypto.BIP39 (unEntropy) -import Simplex.Messaging.Crypto.BIP44 (mkAccountIndex, unAccountIndex) +import Simplex.Messaging.Crypto.BIP44 (AccountIndex, mkAccountIndex, unAccountIndex) import Simplex.Messaging.Util (liftEitherWith) #if defined(dbPostgres) @@ -47,7 +48,7 @@ type SeedId = Int64 data Wallet = Wallet { walletId :: SeedId, walletMaster :: WalletMaster, - nextAccount :: Maybe Word32 + nextAccountIndex :: Maybe Word32 } getWallet :: DB.Connection -> ExceptT StoreError IO (Maybe Wallet) @@ -56,8 +57,8 @@ getWallet db = >>= mapM toWallet where toWallet :: (SeedId, ByteString, ByteString, Maybe Word32) -> ExceptT StoreError IO Wallet - toWallet (walletId, entropy, master, nextAccount) = - liftEitherWith SEInternalError $ (\walletMaster -> Wallet {walletId, walletMaster, nextAccount}) <$> parseWalletMaster (BA.convert entropy) (BA.convert master) + toWallet (walletId, entropy, master, nextAccountIndex) = + liftEitherWith SEInternalError $ (\walletMaster -> Wallet {walletId, walletMaster, nextAccountIndex}) <$> parseWalletMaster (BA.convert entropy) (BA.convert master) createWallet :: DB.Connection -> WalletMaster -> Maybe Word32 -> IO Bool createWallet db master nextAccount = @@ -75,16 +76,19 @@ deleteWallet :: DB.Connection -> IO Bool deleteWallet db = rowReturned $ DB.query_ db "DELETE FROM wallet_seeds RETURNING wallet_seed_id" -resolveAccount :: DB.Connection -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (Wallet, AccountIndex)) -resolveAccount db accountIdx_ = +resolveAccount :: DB.Connection -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (WalletMaster, AccountIndex)) +resolveAccount db accountIdx_ = fmap (first walletMaster) <$> resolveAccount_ db accountIdx_ + +resolveAccount_ :: DB.Connection -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (Wallet, AccountIndex)) +resolveAccount_ db accountIdx_ = getWallet db >>= \case Nothing -> pure $ Left WENoMaster - Just w@Wallet {nextAccount} -> pure $ (w,) <$> maybe next Right accountIdx_ + Just w@Wallet {nextAccountIndex} -> pure $ (w,) <$> maybe next Right accountIdx_ where - next = maybe (Left WECounterUnknown) (maybe (Left WEAccountsExhausted) Right . mkAccountIndex) nextAccount + next = maybe (Left WECounterUnknown) (first (const WEAccountsExhausted) . mkAccountIndex) nextAccountIndex -getUserAccounts :: DB.Connection -> SeedId -> UserId -> IO [AccountIndex] -getUserAccounts db sId userId = +getUserAccounts :: DB.Connection -> UserId -> SeedId -> IO [AccountIndex] +getUserAccounts db userId sId = map fromOnly <$> DB.query db @@ -101,19 +105,19 @@ accountUser db sId n = maybeFirstRow fromOnly $ DB.query db "SELECT user_id FROM wallet_accounts WHERE wallet_seed_id = ? AND account_index = ?" (sId, n) -bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (Wallet, AccountIndex)) -bindAccount db userId accountIdx_ = resolveAccount db accountIdx_ >>= either (pure . Left) (liftIO . bind) +bindAccount :: DB.Connection -> UserId -> Maybe AccountIndex -> ExceptT StoreError IO (Either WalletError (WalletMaster, AccountIndex)) +bindAccount db userId accountIdx_ = resolveAccount_ db accountIdx_ >>= either (pure . Left) (liftIO . bind) where - bind r@(Wallet {walletId}, n) = do + bind (Wallet {walletId, walletMaster}, n) = do held <- accountUser db walletId n >>= \case Just (Just heldBy) -> pure $ heldBy == userId - Just Nothing -> setAccountUser db walletId userId n - Nothing -> True <$ insertAccount db walletId userId n - if held then Right r <$ raiseNextAccount db walletId n else pure $ Left WEAccountBound + Just Nothing -> setAccountUser db userId walletId n + Nothing -> True <$ insertAccount db userId walletId n + if held then Right (walletMaster, n) <$ raiseNextAccount db walletId n else pure $ Left WEAccountBound -setAccountUser :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bool -setAccountUser db sId userId n = +setAccountUser :: DB.Connection -> UserId -> SeedId -> AccountIndex -> IO Bool +setAccountUser db userId sId n = rowReturned $ DB.query db @@ -124,8 +128,11 @@ setAccountUser db sId userId n = |] (userId, sId, n) -accountHeldBy :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO Bool -accountHeldBy db sId userId n = (== Just (Just userId)) <$> accountUser db sId n +heldAccount :: DB.Connection -> UserId -> AccountIndex -> ExceptT StoreError IO (Either WalletError WalletMaster) +heldAccount db userId n = + getWallet db >>= \case + Nothing -> pure $ Left WENoMaster + Just Wallet {walletId, walletMaster} -> liftIO $ (\held -> if held == Just (Just userId) then Right walletMaster else Left WEAccountNotHeld) <$> accountUser db walletId n raiseNextAccount :: DB.Connection -> SeedId -> AccountIndex -> IO () raiseNextAccount db sId n = @@ -137,8 +144,8 @@ raiseNextAccount db sId n = |] (unAccountIndex n + 1, sId, n) -insertAccount :: DB.Connection -> SeedId -> UserId -> AccountIndex -> IO () -insertAccount db sId userId n = +insertAccount :: DB.Connection -> UserId -> SeedId -> AccountIndex -> IO () +insertAccount db userId sId n = DB.execute db "INSERT INTO wallet_accounts (wallet_seed_id, account_index, user_id) VALUES (?, ?, ?)" (sId, n, userId) rowReturned :: IO [Only Int64] -> IO Bool diff --git a/src/Simplex/Chat/View.hs b/src/Simplex/Chat/View.hs index ae217b9dfc..d0b11d6cbf 100644 --- a/src/Simplex/Chat/View.hs +++ b/src/Simplex/Chat/View.hs @@ -60,7 +60,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 Simplex.Chat.Wallet (WalletAddress (..), WalletError (..), WalletInfo (..)) import qualified Simplex.FileTransfer.Transport as XFTP import Simplex.Messaging.Agent (DatabaseDiff (..)) import Simplex.Messaging.Agent.Client (ProtocolTestFailure (..), ProtocolTestStep (..), SubscriptionsInfo (..)) @@ -197,10 +197,12 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te CRBadgeRedeemed u badge newBadge _ -> ttyUser u $ if newBadge then "badge redeemed" : viewContactBadge (Just badge) else ["badge already redeemed"] CRBadgeState u st -> ttyUser u $ viewUserBadgeState st CRBadgeLedger u entries -> ttyUser u $ viewBadgeLedger entries - CRWallet u accounts_ -> ttyUser u $ case accounts_ of + CRWallet u info_ -> ttyUser u $ case info_ of Nothing -> ["no wallet on this device"] - Just [] -> ["wallet, no accounts for this profile"] - Just accounts -> [plain $ "accounts: " <> T.intercalate ", " (map (tshow . unAccountIndex) accounts)] + Just WalletInfo {accountIndexes, nextAccountIndex} -> + let next = maybe "unknown" tshow nextAccountIndex + accounts = if null accountIndexes then "no accounts for this profile" else "accounts: " <> T.intercalate ", " (map (tshow . unAccountIndex) accountIndexes) + in [plain $ "wallet, next account " <> next <> ", " <> 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] @@ -1113,7 +1115,7 @@ viewChatCleared (AChatInfo _ chatInfo) = case chatInfo of walletAddressRow :: WalletAddress -> StyledString walletAddressRow WalletAddress {accountIndex, keyPath, address} = - plain $ tshow (unAccountIndex accountIndex) <> " " <> keyPath <> " " <> address + plain $ tshow (unAccountIndex accountIndex) <> " " <> keyPath <> " " <> safeDecodeUtf8 (strEncode address) viewContactsList :: [Contact] -> [StyledString] viewContactsList = @@ -2817,7 +2819,7 @@ viewChatError isCmd logLevel testView = \case reason = case walletErr of WENoMaster -> "this device has no wallet" WEMasterExists -> "this device already has a wallet" - WEBadMnemonic -> "not a valid 24 word recovery phrase" + WEBadMnemonic -> "not a valid recovery phrase" WEHiddenProfile -> "an account cannot be bound to a hidden profile" WEAccountBound -> "another profile holds this account" WEAccountNotHeld -> "this profile does not hold this account" diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index b5cfd922d2..b2e03ebb71 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -2,14 +2,14 @@ {-# LANGUAGE TemplateHaskell #-} module Simplex.Chat.Wallet - ( AccountIndex, - AccountKey, + ( AccountKey, WalletAddress (..), + WalletInfo (..), WalletError (..), newWalletMaster, entropyFromMnemonic, importWalletMaster, - seedMnemonic, + masterMnemonic, deriveAccount, accountSecret, ) @@ -18,15 +18,16 @@ where import Control.Concurrent.STM import Crypto.Random (ChaChaDRG) import qualified Data.Aeson.TH as JQ +import Data.Bifunctor (first) import qualified Data.ByteArray.Encoding as BAE import Data.Text (Text) import Data.Text.Encoding (decodeLatin1) +import Data.Word (Word32) import qualified Simplex.Messaging.Crypto.BIP32 as B32 import qualified Simplex.Messaging.Crypto.BIP39 as B39 import Simplex.Messaging.Crypto.BIP44 (AccountIndex, CoinType (..), bip44Path) import qualified Simplex.Messaging.Crypto.Secp256k1 as S -import Simplex.Messaging.Encoding.String (strEncode) -import Simplex.Messaging.Eth.Address (addressFromPrivateKey) +import Simplex.Messaging.Eth.Address (Address, addressFromPrivateKey) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) type AccountKey = S.Secp256k1PrivateKey @@ -34,7 +35,13 @@ type AccountKey = S.Secp256k1PrivateKey data WalletAddress = WalletAddress { accountIndex :: AccountIndex, keyPath :: Text, - address :: Text + address :: Address + } + deriving (Show) + +data WalletInfo = WalletInfo + { accountIndexes :: [AccountIndex], + nextAccountIndex :: Maybe Word32 } deriving (Show) @@ -53,24 +60,22 @@ masterStrength :: B39.EntropyStrength masterStrength = B39.ES256 newWalletMaster :: TVar ChaChaDRG -> IO B32.WalletMaster -newWalletMaster g = (`B32.mkWalletMaster` "") <$> atomically (B39.randomEntropy masterStrength g) +newWalletMaster g = B32.mkWalletMaster <$> atomically (B39.randomEntropy masterStrength g) entropyFromMnemonic :: Text -> Either WalletError B39.WalletEntropy -entropyFromMnemonic phrase = case B39.parsePhrase phrase of - Right ent | B39.entropyWordCount ent == 24 -> Right ent - _ -> Left WEBadMnemonic +entropyFromMnemonic = first (const WEBadMnemonic) . B39.parsePhrase importWalletMaster :: Text -> Either WalletError B32.WalletMaster -importWalletMaster phrase = (`B32.mkWalletMaster` "") <$> entropyFromMnemonic phrase +importWalletMaster phrase = B32.mkWalletMaster <$> entropyFromMnemonic phrase -seedMnemonic :: B32.WalletMaster -> Text -seedMnemonic = decodeLatin1 . B39.entropyPhrase . B32.masterEntropy +masterMnemonic :: B32.WalletMaster -> Text +masterMnemonic = decodeLatin1 . B39.entropyPhrase . B32.masterEntropy deriveAccount :: TVar ChaChaDRG -> B32.WalletMaster -> AccountIndex -> IO (AccountKey, WalletAddress) deriveAccount g master n = do k <- B32.xkKey <$> B32.derivePath g (B32.walletMasterKey master) path a <- addressFromPrivateKey g k - pure (k, WalletAddress {accountIndex = n, keyPath = decodeLatin1 $ B32.renderPath path, address = decodeLatin1 $ strEncode a}) + pure (k, WalletAddress {accountIndex = n, keyPath = decodeLatin1 $ B32.renderPath path, address = a}) where path = bip44Path Ethereum n @@ -79,4 +84,6 @@ accountSecret k = "0x" <> decodeLatin1 (BAE.convertToBase BAE.Base16 $ S.unPriva $(JQ.deriveJSON defaultJSON ''WalletAddress) +$(JQ.deriveJSON defaultJSON ''WalletInfo) + $(JQ.deriveJSON (sumTypeJSON $ dropPrefix "WE") ''WalletError) diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index f013af1102..a76f4f16ba 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -11,11 +11,10 @@ import qualified Data.ByteString.Char8 as B import Data.Char (toUpper) import Data.Either (isRight) import Data.List (nub) -import Data.Maybe (fromJust) import Data.Text (Text) import qualified Data.Text as T import Data.Word (Word32) -import Simplex.Chat.Wallet (AccountKey, WalletAddress (..), WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, seedMnemonic) +import Simplex.Chat.Wallet (AccountKey, WalletAddress (..), WalletError (..), accountSecret, deriveAccount, entropyFromMnemonic, masterMnemonic) import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.BIP32 as B32 import qualified Simplex.Messaging.Crypto.BIP39 as B39 @@ -33,12 +32,12 @@ testPhrase24 :: Text testPhrase24 = T.unwords $ replicate 23 "abandon" <> ["art"] walletMaster :: Text -> B32.WalletMaster -walletMaster phrase = B32.mkWalletMaster (either error id $ B39.parsePhrase phrase) "" +walletMaster phrase = B32.mkWalletMaster (either error id $ B39.parsePhrase phrase) walletAccount :: Text -> Word32 -> IO (AccountKey, WalletAddress) walletAccount phrase n = do g <- C.newRandom - deriveAccount g (walletMaster phrase) (fromJust $ mkAccountIndex n) + deriveAccount g (walletMaster phrase) (either error id $ mkAccountIndex n) addressFromSecret :: String -> IO String addressFromSecret secret = do @@ -57,7 +56,7 @@ accountBound cc idx = (take 1 . words <$> getTermLine cc) `shouldReturn` [idx] walletDerivationTests :: Spec walletDerivationTests = do Hspec.it "derives the addresses another wallet derives for the same phrase" $ do - let addrOf n = address . snd <$> walletAccount testPhrase12 n + let addrOf n = strEncode . address . snd <$> walletAccount testPhrase12 n addrOf 0 `shouldReturn` "0x9858EfFD232B4033E47d90003D41EC34EcaEda94" addrOf 1 `shouldReturn` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265" Hspec.it "the exported secret is the one another wallet shows for that account" $ do @@ -75,10 +74,10 @@ walletDerivationTests = do (keyPath . snd <$> walletAccount testPhrase12 0) `shouldReturn` "m/44'/60'/0'/0/0" (keyPath . snd <$> walletAccount testPhrase12 7) `shouldReturn` "m/44'/60'/7'/0/0" Hspec.it "round-trips the phrase it was imported from" $ - seedMnemonic (walletMaster testPhrase24) `shouldBe` testPhrase24 - Hspec.it "accepts only 24 words with a valid checksum" $ do + masterMnemonic (walletMaster testPhrase24) `shouldBe` testPhrase24 + Hspec.it "accepts a phrase of any BIP-39 length with a valid checksum" $ do entropyFromMnemonic testPhrase24 `shouldSatisfy` isRight - entropyFromMnemonic testPhrase12 `shouldBe` Left WEBadMnemonic + entropyFromMnemonic testPhrase12 `shouldSatisfy` isRight entropyFromMnemonic (T.unwords $ replicate 24 "abandon") `shouldBe` Left WEBadMnemonic walletTests :: SpecWith TestParams @@ -110,50 +109,56 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet 1" alice <## "no wallet on this device" alice ##> "/_wallet create new" - alice <## "wallet, no accounts for this profile" + alice <## "wallet, next account 1, no accounts for this profile" alice ##> "/_wallet create new" alice <## "wallet: this device already has a wallet" alice ##> "/_wallet delete" alice <## "ok" alice ##> ("/_wallet create mnemonic=" <> unwords (replicate 24 "abandon")) - alice <## "wallet: not a valid 24 word recovery phrase" + alice <## "wallet: not a valid recovery phrase" alice ##> ("/_wallet create mnemonic=" <> map toUpper (T.unpack testPhrase24)) - alice <## "wallet, no accounts for this profile" + alice <## "wallet, next account unknown, no accounts for this profile" alice ##> "/_wallet export master" alice <## T.unpack testPhrase24 + alice ##> "/_wallet delete" + alice <## "ok" + alice ##> ("/_wallet create mnemonic=" <> T.unpack testPhrase12) + alice <## "wallet, next account unknown, no accounts for this profile" + alice ##> "/_wallet address account=0" + alice <## "0 m/44'/60'/0'/0/0 0x9858EfFD232B4033E47d90003D41EC34EcaEda94" 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 1" - alice `accountBound` "0" + alice <## "wallet, next account 1, no accounts for this profile" alice ##> "/_wallet bind 1" alice `accountBound` "1" + alice ##> "/_wallet bind 1" + alice `accountBound` "2" alice ##> "/_wallet bind 1 account=0" alice `accountBound` "0" alice ##> "/_wallet 1" - alice <## "accounts: 0, 1" + alice <## "wallet, next account 3, accounts: 0, 1, 2" 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, next account 1, no accounts for this profile" alice ##> "/_wallet bind 1" - alice `accountBound` "0" + alice `accountBound` "1" alice ##> "/create user alisa" showActiveUser alice "alisa" alice ##> "/_wallet 2" - alice <## "wallet, no accounts for this profile" + alice <## "wallet, next account 2, no accounts for this profile" alice ##> "/_wallet bind 2" - alice `accountBound` "1" - alice ##> "/_wallet bind 2 account=0" + alice `accountBound` "2" + alice ##> "/_wallet bind 2 account=1" 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" + alice <## "wallet, next account 1, no accounts for this profile" alice ##> "/_wallet bind 1 account=2" alice `accountBound` "2" alice ##> "/_wallet bind 1" @@ -161,34 +166,34 @@ testWalletBindByIndexThenNext ps = withNewTestChat ps "alice" aliceProfile $ \al alice ##> "/_wallet bind 1 account=1" alice `accountBound` "1" alice ##> "/_wallet 1" - alice <## "accounts: 1, 2, 3" + alice <## "wallet, next account 4, accounts: 1, 2, 3" alice ##> "/_wallet bind 1" alice `accountBound` "4" testWalletDeletedProfileAccount :: HasCallStack => TestParams -> IO () testWalletDeletedProfileAccount ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create new" - alice <## "wallet, no accounts for this profile" + alice <## "wallet, next account 1, no accounts for this profile" alice ##> "/_wallet bind 1" - alice `accountBound` "0" + alice `accountBound` "1" alice ##> "/create user alisa" showActiveUser alice "alisa" alice ##> "/delete user alice" alice <### ["ok", "completed deleting user"] alice ##> "/_wallet 2" - alice <## "wallet, no accounts for this profile" - alice ##> "/_wallet bind 2 account=0" - alice `accountBound` "0" + alice <## "wallet, next account 2, no accounts for this profile" + alice ##> "/_wallet bind 2 account=1" + alice `accountBound` "1" testWalletAddress :: HasCallStack => TestParams -> IO () testWalletAddress ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create new" - alice <## "wallet, no accounts for this profile" + alice <## "wallet, next account 1, no accounts for this profile" alice ##> "/_wallet address" addr <- getTermLine alice alice ##> "/_wallet address" getTermLine alice `shouldReturn` addr - words addr !! 1 `shouldBe` "m/44'/60'/0'/0/0" + words addr !! 1 `shouldBe` "m/44'/60'/1'/0/0" alice ##> "/_wallet address account=3" at3 <- getTermLine alice words at3 !! 1 `shouldBe` "m/44'/60'/3'/0/0" @@ -197,12 +202,12 @@ testWalletAddress ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet bind 1 account=abc" alice <## "bad chat command: Failed reading: empty" alice ##> "/_wallet 1" - alice <## "wallet, no accounts for this profile" + alice <## "wallet, next account 1, no accounts for this profile" testWalletExport :: HasCallStack => TestParams -> IO () testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet create mnemonic=" <> T.unpack testPhrase24) - alice <## "wallet, no accounts for this profile" + alice <## "wallet, next account unknown, no accounts for this profile" alice ##> "/_wallet export master" alice <## T.unpack testPhrase24 alice ##> "/_wallet bind 1 account=0" @@ -219,7 +224,7 @@ testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do (idx', path', addr', secret') <- exportRow <$> getTermLine alice idx' `shouldBe` "1" path' `shouldBe` "m/44'/60'/1'/0/0" - (T.unpack . address . snd <$> walletAccount testPhrase24 1) `shouldReturn` addr' + (B.unpack . strEncode . address . snd <$> walletAccount testPhrase24 1) `shouldReturn` addr' addressFromSecret secret' `shouldReturn` addr' alice ##> "/_wallet address account=1" (words <$> getTermLine alice) `shouldReturn` ["1", "m/44'/60'/1'/0/0", addr'] @@ -227,7 +232,7 @@ testWalletExport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do testWalletImport :: HasCallStack => TestParams -> IO () testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> ("/_wallet create mnemonic=" <> T.unpack testPhrase24) - alice <## "wallet, no accounts for this profile" + alice <## "wallet, next account unknown, no accounts for this profile" alice ##> "/_wallet bind 1" alice <## "wallet: the next account is unknown after an import" alice ##> "/_wallet address" @@ -241,14 +246,14 @@ 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, next account 1, no accounts for this profile" alice ##> "/_wallet bind 1 account=2" alice `accountBound` "2" alice ##> "/_wallet export master" getTermLine alice withTestChat ps "alice" $ \alice -> do alice ##> "/_wallet 1" - alice <## "accounts: 2" + alice <## "wallet, next account 3, accounts: 2" alice ##> "/_wallet export master" alice <## phrase alice ##> "/_wallet bind 1" @@ -257,7 +262,7 @@ testWalletPersists ps = do testWalletDelete :: HasCallStack => TestParams -> IO () testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create new" - alice <## "wallet, no accounts for this profile" + alice <## "wallet, next account 1, no accounts for this profile" alice ##> "/_wallet bind 1 account=1" alice `accountBound` "1" alice ##> "/_wallet delete" @@ -265,14 +270,14 @@ testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet 1" alice <## "no wallet on this device" alice ##> "/_wallet create new" - alice <## "wallet, no accounts for this profile" + alice <## "wallet, next account 1, no accounts for this profile" alice ##> "/_wallet bind 1" - alice `accountBound` "0" + alice `accountBound` "1" testWalletHiddenProfile :: HasCallStack => TestParams -> IO () testWalletHiddenProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create new" - alice <## "wallet, no accounts for this profile" + alice <## "wallet, next account 1, no accounts for this profile" alice ##> "/create user alisa" showActiveUser alice "alisa" alice ##> "/hide user my_password" @@ -285,15 +290,15 @@ testWalletHiddenProfile ps = withNewTestChat ps "alice" aliceProfile $ \alice -> testWalletExportNotHeld :: HasCallStack => TestParams -> IO () testWalletExportNotHeld ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create new" - alice <## "wallet, no accounts for this profile" + alice <## "wallet, next account 1, no accounts for this profile" alice ##> "/_wallet bind 1" - alice `accountBound` "0" - alice ##> "/_wallet export account 1 0" + alice `accountBound` "1" + alice ##> "/_wallet export account 1 1" (_, path, _, _) <- exportRow <$> getTermLine alice - path `shouldBe` "m/44'/60'/0'/0/0" + path `shouldBe` "m/44'/60'/1'/0/0" alice ##> "/create user alisa" showActiveUser alice "alisa" - alice ##> "/_wallet export account 2 0" + alice ##> "/_wallet export account 2 1" alice <## "wallet: this profile does not hold this account" alice ##> "/_wallet export account 2 7" alice <## "wallet: this profile does not hold this account" @@ -301,7 +306,7 @@ testWalletExportNotHeld ps = withNewTestChat ps "alice" aliceProfile $ \alice -> testWalletIndexTooLarge :: HasCallStack => TestParams -> IO () testWalletIndexTooLarge ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice ##> "/_wallet create new" - alice <## "wallet, no accounts for this profile" + alice <## "wallet, next account 1, no accounts for this profile" alice ##> "/_wallet address account=2147483648" alice <## "bad chat command: Failed reading: empty" alice ##> "/_wallet bind 1 account=2147483648" From cfe6a69acd400f69d11ba4d36ef37e403da9e969 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 28 Sep 2026 15:31:46 +0200 Subject: [PATCH 64/67] repin to mq, derivation fallible again --- cabal.project | 2 +- docs/rfcs/2026-09-10-wallet-keys.md | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Library/Commands.hs | 13 ++++++++----- src/Simplex/Chat/Wallet.hs | 16 +++++++--------- tests/WalletTests.hs | 4 ++-- 6 files changed, 20 insertions(+), 19 deletions(-) diff --git a/cabal.project b/cabal.project index 4df24c38cb..7438e42478 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 360a82cf7d461a34b9a1709f2b2c60c5cf7188ce + tag: eed21ae122e40f96c00e2cc23688af131359b1f6 source-repository-package type: git diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index f44ff94357..ddd41f314e 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -85,7 +85,7 @@ A command that acts on a profile's accounts names the profile and is rejected wh `bind` without `account=` binds the account at a counter on the master, which is a high-water mark and not a count of bound accounts, and returns the bound account's index, path and address. With `account=` it binds that account, which is how an account found by a scan is attached to the profile it belongs to, and it is rejected for an account another profile holds. After an import the counter is unknown rather than zero, because the phrase does not encode how many accounts it has been used for, so binding the next account is rejected until a scan sets the counter, while binding a known account is still allowed. -BIP-32 marks an index as hardened by setting its top bit, so an index at or above 2^31 already has that bit set and derives the same key as the index 2^31 below it: account 2^31 is account 0. That is a collision, not a loss of hardening, and it would put one key under two account indexes. Derivation itself cannot fail: for the one key in 2^128 that BIP-32 declares invalid, simplexmq recomputes it as SLIP-0010 specifies and Trezor implements, instead of skipping the index, so every account index has a key. An account index is a simplexmq type that only holds values below 2^31, so the command parser rejects 2^31 and above as a bad command, the columns have CHECK constraints for that bound, and reading a row that violates it is an error. The counter's bound is one higher than an account's, because it contains the next index to bind, and 2^31 there means the counter has passed every index that can be hardened, which `bind` without an index reports. +BIP-32 marks an index as hardened by setting its top bit, so an index at or above 2^31 already has that bit set and derives the same key as the index 2^31 below it: account 2^31 is account 0. That is a collision, not a loss of hardening, and it would put one key under two account indexes. For the one key in 2^128 that BIP-32 declares invalid, simplexmq recomputes it as SLIP-0010 specifies and Trezor implements, instead of skipping the index, up to three times; a third failure is reported as an internal error, not a wallet condition, since no input is expected to reach it. An account index is a simplexmq type that only holds values below 2^31, so the command parser rejects 2^31 and above as a bad command, the columns have CHECK constraints for that bound, and reading a row that violates it is an error. The counter's bound is one higher than an account's, because it contains the next index to bind, and 2^31 there means the counter has passed every index that can be hardened, which `bind` without an index reports. `address` reads the counter without changing it, so two calls return the same address, and it derives an address for an account the database has no row for, which a device that lost its database requires. One address per call is sufficient: a caller that scans the tree calls it in a loop. diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 9d74dfe64b..838386106d 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."360a82cf7d461a34b9a1709f2b2c60c5cf7188ce" = "1hq5j8yx3kj1iwxj1pmjixxac40rwkh9mizkfpcf1iyprjsmbi6y"; + "https://github.com/simplex-chat/simplexmq.git"."eed21ae122e40f96c00e2cc23688af131359b1f6" = "0rivm2wdz15085rnljv0g851zybirgpzjfkw1gisxsn47v4x5g0c"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index b23aa20feb..32f7821fc3 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -65,7 +65,7 @@ import Simplex.Chat.Badges.Types (BadgeAlert (..), BadgeAlertKind (..), BadgeIss import Simplex.Chat.Badges.Code (badgeCodeText, parseBadgeCode) import Simplex.Chat.Badges.Service (BadgeBalance (..), BadgeServiceCommand (..), BadgeServiceErrorCode (..), BadgeServiceRequest (..), BadgeServiceResponse (..), BadgeStatement (..), StatementDebitType (..), StatementEntry (..), StatementEntryType (..), currentBadgeServiceVersion) import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim) -import Simplex.Chat.Wallet (AccountKey, WalletAddress, WalletError (..), WalletInfo (..), accountSecret, deriveAccount, importWalletMaster, masterMnemonic, newWalletMaster) +import Simplex.Chat.Wallet (AccountKey, WalletAddress, WalletError (..), WalletInfo (..), accountSecret, deriveAccount, entropyFromMnemonic, masterMnemonic, newWalletMaster) import Simplex.Chat.Call import Simplex.Chat.Controller import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..)) @@ -116,7 +116,7 @@ import Simplex.Messaging.Agent.Store.Interface (getCurrentMigrations) import Simplex.Messaging.Client (NetworkConfig (..), NetworkRequestMode (..), NetworkTimeout (..), SMPWebPortServers (..), SocksMode (SMAlways), pattern NRMInteractive, textToHostMode) import qualified Simplex.Messaging.Crypto as C import qualified Simplex.Messaging.Crypto.ShortLink as SL -import Simplex.Messaging.Crypto.BIP32 (WalletMaster) +import Simplex.Messaging.Crypto.BIP32 (WalletMaster, mkWalletMaster) import Simplex.Messaging.Crypto.BIP44 (AccountIndex, mkAccountIndex) import Simplex.Messaging.Crypto.File (CryptoFile (..), CryptoFileArgs (..)) import qualified Simplex.Messaging.Crypto.File as CF @@ -1508,8 +1508,8 @@ processChatCommand cxt nm = \case when (isJust wallet_) $ throwWalletError WEMasterExists -- the counter starts at 1 for a generated seed, leaving account 0 to other wallets, and is unknown for an imported one (master, nextAccount) <- case mnemonic_ of - Nothing -> (,Just 1) <$> (liftIO . newWalletMaster =<< asks random) - Just phrase -> (,Nothing) <$> liftWallet (importWalletMaster phrase) + Nothing -> (,Just 1) <$> (liftDerivation . newWalletMaster =<< asks random) + Just phrase -> (,Nothing) <$> (liftDerivation . pure . mkWalletMaster =<< liftWallet (entropyFromMnemonic phrase)) created <- withFastStore' $ \db -> createWallet db master nextAccount unless created $ throwWalletError WEMasterExists pure $ CRWallet user (Just $ WalletInfo [] nextAccount) @@ -6037,7 +6037,10 @@ withWalletStore action = liftWallet =<< withFastStore action walletAccount :: WalletMaster -> AccountIndex -> CM (AccountKey, WalletAddress) walletAccount master n = do g <- asks random - liftIO $ deriveAccount g master n + liftDerivation $ deriveAccount g master n + +liftDerivation :: IO (Either String a) -> CM a +liftDerivation = liftError' (ChatError . CEInternalError) chatCommandP :: Parser ChatCommand chatCommandP = diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index b2e03ebb71..90b25dcbf6 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -8,7 +8,6 @@ module Simplex.Chat.Wallet WalletError (..), newWalletMaster, entropyFromMnemonic, - importWalletMaster, masterMnemonic, deriveAccount, accountSecret, @@ -16,6 +15,8 @@ module Simplex.Chat.Wallet where import Control.Concurrent.STM +import Control.Monad.Except +import Control.Monad.IO.Class (liftIO) import Crypto.Random (ChaChaDRG) import qualified Data.Aeson.TH as JQ import Data.Bifunctor (first) @@ -59,22 +60,19 @@ data WalletError masterStrength :: B39.EntropyStrength masterStrength = B39.ES256 -newWalletMaster :: TVar ChaChaDRG -> IO B32.WalletMaster +newWalletMaster :: TVar ChaChaDRG -> IO (Either String B32.WalletMaster) newWalletMaster g = B32.mkWalletMaster <$> atomically (B39.randomEntropy masterStrength g) entropyFromMnemonic :: Text -> Either WalletError B39.WalletEntropy entropyFromMnemonic = first (const WEBadMnemonic) . B39.parsePhrase -importWalletMaster :: Text -> Either WalletError B32.WalletMaster -importWalletMaster phrase = B32.mkWalletMaster <$> entropyFromMnemonic phrase - masterMnemonic :: B32.WalletMaster -> Text masterMnemonic = decodeLatin1 . B39.entropyPhrase . B32.masterEntropy -deriveAccount :: TVar ChaChaDRG -> B32.WalletMaster -> AccountIndex -> IO (AccountKey, WalletAddress) -deriveAccount g master n = do - k <- B32.xkKey <$> B32.derivePath g (B32.walletMasterKey master) path - a <- addressFromPrivateKey g k +deriveAccount :: TVar ChaChaDRG -> B32.WalletMaster -> AccountIndex -> IO (Either String (AccountKey, WalletAddress)) +deriveAccount g master n = runExceptT $ do + k <- B32.xkKey <$> ExceptT (B32.derivePath g (B32.walletMasterKey master) path) + a <- liftIO $ addressFromPrivateKey g k pure (k, WalletAddress {accountIndex = n, keyPath = decodeLatin1 $ B32.renderPath path, address = a}) where path = bip44Path Ethereum n diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index a76f4f16ba..6f3719fde8 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -32,12 +32,12 @@ testPhrase24 :: Text testPhrase24 = T.unwords $ replicate 23 "abandon" <> ["art"] walletMaster :: Text -> B32.WalletMaster -walletMaster phrase = B32.mkWalletMaster (either error id $ B39.parsePhrase phrase) +walletMaster phrase = either error id $ B32.mkWalletMaster (either error id $ B39.parsePhrase phrase) walletAccount :: Text -> Word32 -> IO (AccountKey, WalletAddress) walletAccount phrase n = do g <- C.newRandom - deriveAccount g (walletMaster phrase) (either error id $ mkAccountIndex n) + either error id <$> deriveAccount g (walletMaster phrase) (either error id $ mkAccountIndex n) addressFromSecret :: String -> IO String addressFromSecret secret = do From 03499c92fee05c74449f1810d6fa57d68b7f1f69 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Mon, 28 Sep 2026 16:03:31 +0200 Subject: [PATCH 65/67] repin --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index 7438e42478..8ad9440394 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: eed21ae122e40f96c00e2cc23688af131359b1f6 + tag: bfd14f19d7fc293facd73a26c857a8c9fe2c51cd source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 838386106d..db27570044 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."eed21ae122e40f96c00e2cc23688af131359b1f6" = "0rivm2wdz15085rnljv0g851zybirgpzjfkw1gisxsn47v4x5g0c"; + "https://github.com/simplex-chat/simplexmq.git"."bfd14f19d7fc293facd73a26c857a8c9fe2c51cd" = "04y9dhrn9l8bwyyk0cxq99zsaprj271yriqcc624jahrln7pckcv"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; From d739fe3c1af2bb0ee3a5057f396c3262e5dce546 Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Tue, 29 Sep 2026 08:52:00 +0000 Subject: [PATCH 66/67] use deriveAddress, update simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- src/Simplex/Chat/Wallet.hs | 10 +++------- 3 files changed, 5 insertions(+), 9 deletions(-) diff --git a/cabal.project b/cabal.project index 8ad9440394..1f41997068 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: bfd14f19d7fc293facd73a26c857a8c9fe2c51cd + tag: 0cbe3b81facade288f4cb4dc2465f70a4bc8117b source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index db27570044..54c2ffed89 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."bfd14f19d7fc293facd73a26c857a8c9fe2c51cd" = "04y9dhrn9l8bwyyk0cxq99zsaprj271yriqcc624jahrln7pckcv"; + "https://github.com/simplex-chat/simplexmq.git"."0cbe3b81facade288f4cb4dc2465f70a4bc8117b" = "1kj700rpnl0797mg7nn5a9kmqxlnpqdy41ffnwpdn30mcdlahfzx"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl"; diff --git a/src/Simplex/Chat/Wallet.hs b/src/Simplex/Chat/Wallet.hs index 90b25dcbf6..4dfb37db97 100644 --- a/src/Simplex/Chat/Wallet.hs +++ b/src/Simplex/Chat/Wallet.hs @@ -15,8 +15,6 @@ module Simplex.Chat.Wallet where import Control.Concurrent.STM -import Control.Monad.Except -import Control.Monad.IO.Class (liftIO) import Crypto.Random (ChaChaDRG) import qualified Data.Aeson.TH as JQ import Data.Bifunctor (first) @@ -28,7 +26,7 @@ import qualified Simplex.Messaging.Crypto.BIP32 as B32 import qualified Simplex.Messaging.Crypto.BIP39 as B39 import Simplex.Messaging.Crypto.BIP44 (AccountIndex, CoinType (..), bip44Path) import qualified Simplex.Messaging.Crypto.Secp256k1 as S -import Simplex.Messaging.Eth.Address (Address, addressFromPrivateKey) +import Simplex.Messaging.Eth.Address (Address, deriveAddress) import Simplex.Messaging.Parsers (defaultJSON, dropPrefix, sumTypeJSON) type AccountKey = S.Secp256k1PrivateKey @@ -70,12 +68,10 @@ masterMnemonic :: B32.WalletMaster -> Text masterMnemonic = decodeLatin1 . B39.entropyPhrase . B32.masterEntropy deriveAccount :: TVar ChaChaDRG -> B32.WalletMaster -> AccountIndex -> IO (Either String (AccountKey, WalletAddress)) -deriveAccount g master n = runExceptT $ do - k <- B32.xkKey <$> ExceptT (B32.derivePath g (B32.walletMasterKey master) path) - a <- liftIO $ addressFromPrivateKey g k - pure (k, WalletAddress {accountIndex = n, keyPath = decodeLatin1 $ B32.renderPath path, address = a}) +deriveAccount g master n = fmap account <$> deriveAddress g (B32.walletMasterKey master) path where path = bip44Path Ethereum n + account (xk, a) = (B32.xkKey xk, WalletAddress {accountIndex = n, keyPath = decodeLatin1 $ B32.renderPath path, address = a}) accountSecret :: AccountKey -> Text accountSecret k = "0x" <> decodeLatin1 (BAE.convertToBase BAE.Base16 $ S.unPrivateKey k) From f41a582b88615f8ed615a6141f90a251a4abb473 Mon Sep 17 00:00:00 2001 From: "Evgeny @ SimpleX Chat" <259188159+evgeny-simplex@users.noreply.github.com> Date: Tue, 29 Sep 2026 10:25:52 +0000 Subject: [PATCH 67/67] simplexmq --- cabal.project | 2 +- scripts/nix/sha256map.nix | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/cabal.project b/cabal.project index 1f41997068..65e1edeccb 100644 --- a/cabal.project +++ b/cabal.project @@ -21,7 +21,7 @@ constraints: zip +disable-bzip2 +disable-zstd source-repository-package type: git location: https://github.com/simplex-chat/simplexmq.git - tag: 0cbe3b81facade288f4cb4dc2465f70a4bc8117b + tag: 26e70edfbf1d23be5a2ea514d4950b419a49fd5c source-repository-package type: git diff --git a/scripts/nix/sha256map.nix b/scripts/nix/sha256map.nix index 54c2ffed89..a669b4401c 100644 --- a/scripts/nix/sha256map.nix +++ b/scripts/nix/sha256map.nix @@ -1,5 +1,5 @@ { - "https://github.com/simplex-chat/simplexmq.git"."0cbe3b81facade288f4cb4dc2465f70a4bc8117b" = "1kj700rpnl0797mg7nn5a9kmqxlnpqdy41ffnwpdn30mcdlahfzx"; + "https://github.com/simplex-chat/simplexmq.git"."26e70edfbf1d23be5a2ea514d4950b419a49fd5c" = "1kj700rpnl0797mg7nn5a9kmqxlnpqdy41ffnwpdn30mcdlahfzx"; "https://github.com/simplex-chat/hs-socks.git"."a30cc7a79a08d8108316094f8f2f82a0c5e1ac51" = "0yasvnr7g91k76mjkamvzab2kvlb1g5pspjyjn2fr6v83swjhj38"; "https://github.com/simplex-chat/direct-sqlcipher.git"."f814ee68b16a9447fbb467ccc8f29bdd3546bfd9" = "1ql13f4kfwkbaq7nygkxgw84213i0zm7c1a8hwvramayxl38dq5d"; "https://github.com/simplex-chat/sqlcipher-simple.git"."a46bd361a19376c5211f1058908fc0ae6bf42446" = "1z0r78d8f0812kxbgsm735qf6xx8lvaz27k1a0b4a2m0sshpd5gl";