From 472ee5ef1012f9c312ea1163d7ed20824e4e2fe9 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Thu, 10 Sep 2026 16:44:15 +0000 Subject: [PATCH] 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