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) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX
This commit is contained in:
Alain Brenzikofer
2026-09-10 16:44:15 +00:00
co-authored by Claude Opus 5
parent 3ad17ca477
commit 472ee5ef10
2 changed files with 28 additions and 14 deletions
@@ -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,
+16 -14
View File
@@ -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