names: wire key selection into derivation, restore marks on rescan

/name keys use never reached deriveNameOwner, so it did not change where a
  purchase went. Pass the selection through.

  Nothing restored the derivation marks after an import, so import + rescan +
  buy derived the recovered name's own key, failing after the code was spent.
  A scan now raises both marks; a purchase re-checks the path first.

  Also from review: three Haddock blocks moved onto their bindings, the
  hardcoded "of 10" dropped, scanSeed's fan-out documented, unused user
  bindings removed.
This commit is contained in:
Alain Brenzikofer
2026-08-29 15:17:21 +02:00
parent 977ffbc3e1
commit 0f10cf30b0
9 changed files with 323 additions and 74 deletions
+6 -2
View File
@@ -13,8 +13,12 @@ needs a wallet, ETH and a browser:
service - metering is off chain.
- See what you own with `/names` and `/name info`, and find names your keys
already hold with `/name rescan`.
- Recovery keys: `/name keys`, `keys export` (every key, not just the one in
use), `keys import` (adds, never replaces), `keys init`, `keys use <n>`.
- Recovery keys: `/name keys` (names grouped by the account they sit under),
`keys export` (every key, not just the one in use), `keys import` (adds, never
replaces), `keys init`, `keys use <n> [<account> [<name>]]`.
- Recovering on a new device: `/name rescan` restores the derivation marks from
the paths it finds, so a later purchase cannot be handed a recovered name's
key; the account a profile held is not in the phrase, so `keys use` takes it.
- Wallet: one key per name at `m/44'/60'/<profile>'/0/<name>` - ordinary BIP-44,
so importing the phrase into another wallet reaches the same addresses.
- The badge service gains the registrar commands, a readable chain mock with real
@@ -39,7 +39,7 @@ one, so the CLI is not bound to a single service.
/name link contact <svc> alice.simplex simplex:/contact#/xyz
alice.simplex: contact updated (tx 0x…)
9 of 10 relayed edits left
9 relayed edits left
```
A second name on the same profile takes the next index — `m/44'/60'/0'/0/1`, a
@@ -67,14 +67,16 @@ Seeing what you own:
path m/44'/60'/0'/0/0
contact simplex:/contact#/xyz
expires 2028-08-27
9 of 10 relayed edits left
9 relayed edits left
```
Recovery keys:
```
/name keys 1: alice.simplex (in use)
2: lucy.simplex (not written down)
/name keys 1: (in use)
account 0 alice.simplex
2: (not written down)
other lucy.simplex (m)
/name keys export every key's phrase, each labelled with the names
it controls - never just the one in use
@@ -82,14 +84,20 @@ Recovery keys:
/name keys import <phrase> adds a key; never replaces one
/name keys init optional - create one before buying
/name keys use 2 which key the next purchase goes under
/name keys use 2 0 ...and which account this profile is
/name keys use 2 0 5 ...and where its next name sits
```
Names are listed under the account they were derived at, because that grouping
is the only surviving record of which profile owned what — see *Recovery*.
A new device, with nothing but the phrase:
```
/name keys import <phrase>
/name rescan <svc> walks the known layouts and the bare root
found alice.simplex
and moves both marks past what it found
/name link contact <svc> alice.simplex simplex:/contact#/new
point it at this device's address
@@ -123,11 +131,11 @@ APINameInfo {target, name} -> CRNameInfo {name, owner, path, con
APINameSetLink {target, name, record, link}
-> CRNameLinkSet {name, record, txHash}
APINameRescan {target, more} -> CRNameRescan [{name, path}]
APINameKeys {} -> CRNameKeys [{n, names, current, backedUp}]
APINameKeys {} -> CRNameKeys [{n, byAccount, current, backedUp}]
APINameKeysExport {} -> CRNameKeyPhrases [{n, phrase, names}]
APINameKeysImport {phrase} -> CRNameKeys …
APINameKeysInit {} -> CRNameKeys …
APINameKeysUse {n} -> CRNameKeys …
APINameKeysUse {n, account?, name?} -> CRNameKeys …
APINameRegister {target, name, link} -> CRNameRegistered …
```
@@ -437,6 +445,31 @@ command reads stdin — the interactive prompts in the codebase all run before t
controller starts — and a command that blocked on input could not be answered by
a GUI client over the JSON API, nor driven by the line-oriented tests.
Selecting a key moves the profile onto it: the next purchase is derived under
that seed, at an account index taken there. The names the profile already owns
are unaffected — `wallet_name_keys` stores the path literally, so they re-derive
from the seed that owns them regardless of where the profile now sits.
### Recovery: what the phrase does not carry
A phrase carries entropy and nothing else. Two things the client needs are not in
it, not on chain, and not derivable:
* **Which profile held which account.** Recreate two profiles on a new device and
nothing says which was account 0. Only the user knows, so `/name keys` groups
names by the account in their stored path and `/name keys use <n> <account>`
pins a profile back to one.
* **Which indices are already taken.** Both high-water marks — `next_account_index`
per seed, `wallet_next_name_index` per profile — start at 0 after an import,
while accounts and names already exist under the phrase. A scan is the only
thing that can restore them, so `/name rescan` moves both past every index it
finds, and `/name keys use <n> <account> <name>` sets the second by hand.
A purchase re-checks the path against `wallet_name_keys` before spending
anything, and steps over one already taken. Discovering the clash from the UNIQUE
constraint instead would come too late: that insert runs after the registration
has gone through and the redemption code has been spent.
`/name keys export` prints **every** key's phrase, each labelled with the names it
controls. Exporting only the key in use would be a trap: a user with two keys who
writes down "the phrase" has not backed up the other, and finds out after losing
@@ -463,6 +496,10 @@ Each of these is a test, not a claim.
a later session.
6. **Commit/reveal still holds.** A reveal with no matching commitment is
refused, and a live name cannot be re-registered.
7. **Recovery does not reuse a key.** A name registered on one device and
recovered on another from the phrase alone does not have its key handed to
the next purchase: after import and rescan the new name lands on a different
account, not on the recovered name's path.
## Known gaps
+3 -2
View File
@@ -69,6 +69,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.Util (liftIOEither)
import Simplex.FileTransfer.Description (FileDescriptionURI)
import Simplex.Messaging.Server.Information (ServerPublicInfo)
@@ -426,7 +427,7 @@ data ChatCommand
| APINameKeysExport
| APINameKeysImport {recoveryPhrase :: Text}
| APINameKeysInit
| APINameKeysUse {keyNo :: Int}
| APINameKeysUse {keyNo :: Int, keyAccount :: Maybe AccountIndex, keyNameIndex :: Maybe NameIndex}
| APISendCallInvitation ContactId CallType
| SendCallInvitation ContactName CallType
| APIRejectCall ContactId
@@ -860,7 +861,7 @@ data ChatResponse
| CRNameInfo {user :: User, regName :: Text, regOwner :: Text, regPath :: Text, nameContact :: [Text], nameChannel :: [Text], regExpiry :: UTCTime, nameEditsLeft :: Word32}
| CRNameLinkSet {user :: User, regName :: Text, nameRecord :: Text, regTxHash :: TxHash}
| CRNameRescan {user :: User, namesFound :: [(Text, Text)]}
| CRNameKeys {user :: User, walletKeys :: [(Int, [Text], Bool, Bool)]}
| CRNameKeys {user :: User, walletKeys :: [(Int, [(Maybe AccountIndex, [(Text, Text)])], Bool, Bool)]}
| CRNameKeyPhrases {user :: User, walletPhrases :: [(Int, Text, [Text])]}
| CRUserAcceptedGroupSent {user :: User, groupInfo :: GroupInfo, hostContact :: Maybe Contact}
| CRUserDeletedMembers {user :: User, groupInfo :: GroupInfo, members :: [GroupMember], withMessages :: Bool, msgSigned :: Bool}
+4 -1
View File
@@ -241,7 +241,10 @@ namesHelpInfo =
indent <> highlight "/name keys export" <> " - every key's phrase, not just the one in use",
indent <> highlight "/name keys import <phrase>" <> " - add a key; never replaces one",
indent <> highlight "/name keys init" <> " - optional: create one before buying",
indent <> highlight "/name keys use <n>" <> " - which key the next purchase goes under",
indent <> highlight "/name keys use <n> [<account> [<name>]]",
indent <> indent <> " - which key the next purchase goes under; after importing a",
indent <> indent <> " phrase, also which account this profile is and where its",
indent <> indent <> " next name sits, neither of which the phrase records",
indent <> highlight "/name rescan <service> [more]" <> " - find names your keys already own",
"",
"A name has its own key, at " <> highlight "m/44'/60'/<profile>'/0/<name>" <> ". That is",
+67 -25
View File
@@ -65,8 +65,8 @@ import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), cla
import Simplex.Chat.Names.Protocol
import Simplex.Chat.Names.Snrc (Intent (..), SnrcDeployment (..), intent712, parseRecordKey)
import Simplex.Messaging.Eth.Address (Address, mkAddress)
import Simplex.Chat.Store.Wallets (boundAccount, createSeed, currentSeed, getNameKeys, getOrCreateAccountRef, listSeeds, markBackedUp, recordNameKey, seedOfName, setCurrentSeed, takeNameIndex)
import Simplex.Chat.Wallet (AccountIndex, AccountRef (..), NameIndex, SeedId, WalletAccount, WalletSeed (..), accountAddress, deriveAtPath, deriveNameKey, ethSignatureBytes, importRecoveryKey, newSeed, recoveryKeyPhrase, renderNameKeyPath, signIntent)
import Simplex.Chat.Store.Wallets (bindSeedAccount, boundAccount, createSeed, currentSeed, getNameKeys, getOrCreateAccountRef, listSeeds, markBackedUp, nameKeyPathTaken, raiseNextAccountIndex, raiseNextNameIndex, recordNameKey, seedOfName, setCurrentSeed, setNextNameIndex, takeNameIndex)
import Simplex.Chat.Wallet (AccountIndex, AccountRef (..), SeedId, WalletAccount, WalletSeed (..), accountAddress, deriveAtPath, deriveNameKey, ethSignatureBytes, importRecoveryKey, newSeed, parseNameKeyPath, recoveryKeyPhrase, renderNameKeyPath, signIntent)
import Simplex.Chat.Call
import Simplex.Chat.Controller
import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..))
@@ -1487,7 +1487,7 @@ processChatCommand cxt nm = \case
-- The registrar owns the code table, so it decides: spent, expired, or too
-- short for the tier. The client does not second-guess it.
let nm' = label <> ".simplex"
(seedId, nameIx, acctIx, owner) <- deriveNameOwner user
(seedId, path, owner) <- deriveNameOwner user
cReq <- resolveServiceTarget nm user sendTarget
g <- asks random
rid <- RequestId <$> atomically (C.randomBytes 16 g)
@@ -1498,7 +1498,6 @@ processChatCommand cxt nm = \case
NRPRegistered {nrExpiry, nrTxHash} -> pure (nrExpiry, nrTxHash)
_ -> throwCmdError "unexpected buy response"
progress NRPhaseRegistered Nothing
let path = renderNameKeyPath acctIx nameIx
withFastStore' $ \db -> recordNameKey db seedId path nm'
pure $ CRNameRegistered user nm' (tshow owner) path expiry' txHash'
APINameList sendTarget -> withUser $ \user -> do
@@ -1542,13 +1541,26 @@ processChatCommand cxt nm = \case
let gap = if more then 20 else 10
found <- fmap concat $ forM seeds $ \seed -> scanSeed user cReq seed gap
forM_ found $ \(nm_, path, sId) -> withFastStore' $ \db -> recordNameKey db sId path nm_
-- A scan is the only thing that can restore the two high-water marks: after
-- an import both start at 0 while accounts and names already exist under
-- the phrase, and a purchase on a mark left at 0 derives a key a recovered
-- name already owns - discovered by the UNIQUE constraint only after the
-- code has been spent.
forM_ found $ \(_, path, sId) -> forM_ (parseNameKeyPath path) $ \(acctIx, nameIx) ->
withFastStore' $ \db -> do
raiseNextAccountIndex db sId (acctIx + 1)
raiseNextNameIndex db user (nameIx + 1)
pure $ CRNameRescan user [(a, b) | (a, b, _) <- found]
-- Grouped by account, because which profile held which account is recorded
-- nowhere else: not on chain, not in the phrase. After recovery on a new
-- device this listing is the only way to tell one profile's names from
-- another's, and so to pin a profile back with /name keys use <n> <account>.
APINameKeys -> withUser $ \user -> do
seeds <- withFastStore' $ \db -> listSeeds db
cur <- withFastStore' $ \db -> currentSeed db user
rows <- forM (zip [1 ..] seeds) $ \(i, (seed, backed)) -> do
ns <- withFastStore' $ \db -> map fst <$> getNameKeys db (wsId seed)
pure (i, ns, Just (wsId seed) == fmap wsId cur, backed)
ns <- withFastStore' $ \db -> getNameKeys db (wsId seed)
pure (i, groupByAccount ns, Just (wsId seed) == fmap wsId cur, backed)
pure $ CRNameKeys user rows
-- Every key, never just the one in use: a user with two keys who writes down
-- "the phrase" and stops has not backed up the other, and finds out after
@@ -1563,26 +1575,32 @@ processChatCommand cxt nm = \case
pure $ CRNameKeyPhrases user rows
-- Adds a key; never replaces one, or the names the existing key owns stop
-- being derivable.
APINameKeysImport phrase -> withUser $ \user -> do
APINameKeysImport phrase -> withUser $ \_ -> do
entropy <- either (throwCmdError . ("wallet: " <>)) pure $ importRecoveryKey (encodeUtf8 phrase)
void $ withFastStore' $ \db -> createSeed db entropy
processChatCommand cxt nm APINameKeys
APINameKeysInit -> withUser $ \user -> do
APINameKeysInit -> withUser $ \_ -> do
g <- asks random
seeds <- withFastStore' $ \db -> listSeeds db
when (null seeds) $ do
entropy <- atomically $ newSeed MS256 g
void $ withFastStore' $ \db -> createSeed db entropy
processChatCommand cxt nm APINameKeys
APINameKeysUse n -> withUser $ \user -> do
-- The account and name index are optional because only recovery needs them:
-- normally both are allocated. After importing a phrase on a new device
-- neither is known to the client, so the user names them from what
-- /name keys shows.
APINameKeysUse n acct_ nameIx_ -> withUser $ \user -> do
seeds <- withFastStore' $ \db -> listSeeds db
case drop (n - 1) seeds of
((seed, _) : _) | n >= 1 -> do
withFastStore' $ \db -> setCurrentSeed db user (wsId seed)
forM_ acct_ $ \acct -> withFastStore' $ \db -> bindSeedAccount db user seed acct
forM_ nameIx_ $ \ix -> withFastStore' $ \db -> setNextNameIndex db user ix
processChatCommand cxt nm APINameKeys
_ -> throwCmdError $ "no key " <> show n
APINameRegister sendTarget nm' sLink -> withUser $ \user -> do
(seedId, nameIx, acctIx, owner) <- deriveNameOwner user
(seedId, path, owner) <- deriveNameOwner user
cReq <- resolveServiceTarget nm user sendTarget
g <- asks random
secret <- NameSecret <$> atomically (C.randomBytes 32 g)
@@ -1612,7 +1630,6 @@ processChatCommand cxt nm = \case
-- Recorded only after the name is actually registered: an index consumed by
-- a failed attempt is simply skipped, which costs nothing, while recording
-- a name we do not own would make the list lie.
let path = renderNameKeyPath acctIx nameIx
withFastStore' $ \db -> recordNameKey db seedId path nm'
pure $ CRNameRegistered user nm' (tshow owner) path expiry' txHash'
@@ -5181,7 +5198,6 @@ resolveServiceTarget nm user = \case
Nothing -> throwChatError $ CESimplexDomainNotReady d SDENoValidLink
resolveShortLink sLnk = (\(_, _, cReq) -> cReq) <$> getShortLinkConnReq nm user sLnk
-- | Default name lifetime (365 days). Real min-commitment-age enforcement deferred.
-- | Signed intents expire in minutes, not days: a long-lived one survives a
-- name changing hands and could then be replayed against the new owner's name.
intentTtlSeconds :: Integer
@@ -5200,27 +5216,43 @@ clientDeployment =
mockClientAddr :: Word8 -> Address
mockClientAddr n = either error id $ mkAddress (BS.replicate 19 0 <> BS.singleton n)
-- | One request, one response, one error convention. Every names command goes
-- through here so a service error ends the command rather than being handled
-- three different ways.
-- | The seed is created on first registration and persisted, so the owner
-- address survives restart — a name whose key we cannot re-derive is lost.
--
-- One key per name: the index is taken here, so a second purchase by the same
-- profile lands on a different address rather than sharing one.
deriveNameOwner :: User -> CM (SeedId, NameIndex, AccountIndex, Address)
deriveNameOwner :: User -> CM (SeedId, Text, Address)
deriveNameOwner user = do
g <- asks random
seeds <- withFastStore' $ \db -> listSeeds db
when (length seeds > 1) $ do
cur <- withFastStore' $ \db -> currentSeed db user
when (isNothing cur) $
throwCmdError "several recovery keys on this device - choose one with /name keys use <n>"
sel <- withFastStore' $ \db -> currentSeed db user
when (length seeds > 1 && isNothing sel) $
throwCmdError "several recovery keys on this device - choose one with /name keys use <n>"
(seed, AccountRef {arIndex}) <-
withFastStore' $ \db -> getOrCreateAccountRef db user (atomically $ newSeed MS256 g)
nameIx <- withFastStore' $ \db -> takeNameIndex db user
withFastStore' $ \db -> getOrCreateAccountRef db user sel (atomically $ newSeed MS256 g)
(nameIx, path) <- freeNameIndex (wsId seed) arIndex (0 :: Int)
acc <- either (throwCmdError . ("wallet: " <>)) pure $ deriveNameKey seed arIndex nameIx
pure (wsId seed, nameIx, arIndex, accountAddress acc)
pure (wsId seed, path, accountAddress acc)
where
-- The name index is a high-water mark, not a search, and a recovery scan
-- can leave it pointing at a path a recovered name already owns. Stepping
-- over those here keeps the clash before the purchase: recordNameKey runs
-- after the code has been spent, so failing there would cost the code.
freeNameIndex sId acctIx tries
| tries >= 100 = throwCmdError "no free name index - set one with /name keys use <n> <account> <name>"
| otherwise = do
nameIx <- withFastStore' $ \db -> takeNameIndex db user
let path = renderNameKeyPath acctIx nameIx
taken <- withFastStore' $ \db -> nameKeyPathTaken db sId path
if taken then freeNameIndex sId acctIx (tries + 1) else pure (nameIx, path)
-- | Names a seed owns, grouped by the account they were derived under. Names on
-- a layout that is not ours have no account of ours and group under Nothing.
groupByAccount :: [(Text, Text)] -> [(Maybe AccountIndex, [(Text, Text)])]
groupByAccount ns =
-- stable, so the accounts keep their order and the ungrouped names sort last
sortOn (isNothing . fst) . M.toAscList . M.fromListWith (flip (<>)) $
map (\(n, path) -> (fst <$> parseNameKeyPath path, [(n, path)])) ns
-- | Names this device holds a key for, with the path each key sits at.
ownedNames :: User -> CM [(Text, Text)]
@@ -5244,6 +5276,12 @@ nameKeyOf user nm' = do
-- registered under: ours, the two common wallet defaults, and the bare master
-- key. The set can only grow — adding a layout finds more and invalidates
-- nothing.
--
-- One round trip per candidate path, taken sequentially: after nub that is
-- 4 * gap + 1, so 41 per seed and 81 for @rescan more@. Affordable only because
-- a scan is an explicit user action taken once after importing a phrase; a
-- batched owned-by-many lookup would be the fix if the service ever
-- rate-limits.
scanSeed :: User -> ConnReqContact -> WalletSeed -> Int -> CM [(Text, Text, SeedId)]
scanSeed user cReq seed gap = do
let paths =
@@ -5264,6 +5302,9 @@ scanSeed user cReq seed gap = do
NRPNames {nrNames} -> pure [(n, path, wsId seed) | n <- nrNames]
_ -> pure []
-- | One request, one response, one error convention. Every names command goes
-- through here so a service error ends the command rather than being handled
-- three different ways.
namesRPC :: User -> ConnReqContact -> NamesCommand -> CM NamesResponse
namesRPC user cReq c = do
let req = NamesRequest currentNamesVersion c
@@ -5272,13 +5313,14 @@ namesRPC user cReq c = do
NRPError {nrCode, nrMessage, nrRetryAfter} -> throwChatError $ CENameRegistrationFailed (textEncode nrCode) nrMessage nrRetryAfter
r -> pure r
-- | A token with no spaces: a record name, or a redemption code.
nameSetLinkP :: ConnectTarget 'CMContact -> Text -> Text -> Text -> ChatCommand
nameSetLinkP target record nm' lnk = APINameSetLink target nm' record lnk
-- | A token with no spaces: a record name, or a redemption code.
nonSpaceTextP :: Parser Text
nonSpaceTextP = safeDecodeUtf8 <$> A.takeTill (== ' ')
-- | Default name lifetime (365 days). Real min-commitment-age enforcement deferred.
defaultNameTtl :: NameTtl
defaultNameTtl = 31536000
@@ -5808,7 +5850,7 @@ chatCommandP =
"/name keys export" $> APINameKeysExport,
"/name keys import " *> (APINameKeysImport <$> textP),
"/name keys init" $> APINameKeysInit,
"/name keys use " *> (APINameKeysUse <$> A.decimal),
"/name keys use " *> (APINameKeysUse <$> A.decimal <*> optional (A.space *> A.decimal) <*> optional (A.space *> A.decimal)),
"/name keys" $> APINameKeys,
"/_call invite @" *> (APISendCallInvitation <$> A.decimal <* A.space <*> jsonP),
"/call " *> char_ '@' *> (SendCallInvitation <$> displayNameP <*> pure defaultCallType),
+80 -21
View File
@@ -6,14 +6,18 @@
-- | 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. Only the single-seed case is reachable from the
-- UI: 'getOrCreateAccountRef' reuses the database's first seed and allocates the
-- next free account index, while 'boundAccount' only reads. Those two are the
-- exports; the helpers below stay internal until they have a caller.
-- plus its own account index. 'getOrCreateAccountRef' resolves that binding for
-- a purchase, moving the profile when another seed has been selected;
-- 'boundAccount' only reads.
module Simplex.Chat.Store.Wallets
( getOrCreateAccountRef,
boundAccount,
takeNameIndex,
setNextNameIndex,
raiseNextNameIndex,
raiseNextAccountIndex,
bindSeedAccount,
nameKeyPathTaken,
recordNameKey,
getNameKeys,
listSeeds,
@@ -27,7 +31,7 @@ where
import Data.ByteString (ByteString)
import Data.Int (Int64)
import Data.Maybe (listToMaybe)
import Data.Maybe (isJust, listToMaybe)
import Data.Text (Text)
import Simplex.Chat.Store.Shared (insertedRowId)
import Simplex.Chat.Types (User (..))
@@ -87,34 +91,47 @@ boundAccount db user =
Nothing -> pure Nothing
Just r -> fmap (\s -> (s, r)) <$> getWalletSeed db (arSeedId r)
-- | Bind this profile to a seed, creating one from @mkSeed@ if the database has
-- none yet, and allocating the next free account index.
-- | Bind this profile to @sel_@, or to the seed it is already on when nothing
-- is selected, creating a seed from @mkSeed@ if the database has none yet.
--
-- Single-seed by construction: it always picks the first existing seed. When
-- multiple seeds become selectable this is the one function that changes.
getOrCreateAccountRef :: DB.Connection -> User -> IO ByteString -> IO (WalletSeed, AccountRef)
getOrCreateAccountRef db user mkSeed = do
-- A profile moves only when a seed is selected: picking the first row in the
-- table instead would silently re-bind a profile whenever a second seed exists
-- - which is exactly what importing a recovery key creates - moving it to a new
-- account index nobody asked for. Moving on selection is safe by contrast:
-- names re-derive from the literal path in wallet_name_keys, so the ones this
-- profile already owns stay reachable under the seed that owns them.
getOrCreateAccountRef :: DB.Connection -> User -> Maybe WalletSeed -> IO ByteString -> IO (WalletSeed, AccountRef)
getOrCreateAccountRef db user sel_ mkSeed = do
existing <- getAccountRef db user
-- Load the seed this profile is actually bound to. Picking the first row in
-- the table instead would silently re-bind a profile whenever a second seed
-- exists - which is exactly what importing a recovery key creates - throwing
-- away the imported key and moving the profile to a new account index, so
-- the names it already owned stop being derivable too.
bound <- case existing of
Just r -> fmap (\s -> (r, s)) <$> getWalletSeed db (arSeedId r)
Nothing -> pure Nothing
case bound of
Just (r, s) -> pure (s, r)
Nothing -> do
seeds <- getWalletSeeds db
s <- case listToMaybe seeds of
Just (r, s) | maybe True ((wsId s ==) . wsId) sel_ -> pure (s, r)
_ -> do
s <- case sel_ of
Just s -> pure s
Nothing -> mkSeed >>= createWalletSeed db
Nothing -> do
seeds <- getWalletSeeds db
case listToMaybe seeds of
Just s -> pure s
Nothing -> mkSeed >>= createWalletSeed db
ix <- takeAccountIndex db (wsId s)
let r = AccountRef {arSeedId = wsId s, arIndex = ix}
bindAccount db user r
pure (s, r)
-- | Pin this profile to an account index the user named, rather than taking the
-- next free one. Which profile held which account is not on chain and not in
-- the phrase, so after recovery on a new device only the user knows it.
--
-- The seed's mark moves past the pinned index too, or the next profile to bind
-- would be handed the same account and derive the same name keys.
bindSeedAccount :: DB.Connection -> User -> WalletSeed -> AccountIndex -> IO ()
bindSeedAccount db user s ix = do
bindAccount db user AccountRef {arSeedId = wsId s, arIndex = ix}
raiseNextAccountIndex db (wsId s) (ix + 1)
-- | 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)@,
@@ -127,6 +144,19 @@ takeAccountIndex db sId@(SeedId sId') = do
DB.execute db "UPDATE wallet_seeds SET next_account_index = ? WHERE wallet_seed_id = ?" (fromIntegral ix + 1 :: Int64, sId')
pure ix
-- | Move a mark forward, never back. Used by a recovery scan, which learns from
-- the paths it found which indices are already spoken for: nothing else
-- restores these after an import, and a mark left at 0 hands the next purchase
-- a key a recovered name already owns.
raiseNextAccountIndex :: DB.Connection -> SeedId -> AccountIndex -> IO ()
raiseNextAccountIndex db (SeedId sId) ix =
DB.execute
db
"UPDATE wallet_seeds SET next_account_index = ? WHERE wallet_seed_id = ? AND next_account_index < ?"
(ix', sId, ix')
where
ix' = fromIntegral ix :: Int64
getNextAccountIndex :: DB.Connection -> SeedId -> IO AccountIndex
getNextAccountIndex db (SeedId sId) =
maybe 0 (fromIntegral :: Int64 -> AccountIndex)
@@ -150,6 +180,35 @@ takeNameIndex db User {userId} = do
DB.execute db "UPDATE users SET wallet_next_name_index = ? WHERE user_id = ?" (fromIntegral ix + 1 :: Int64, userId)
pure ix
-- | See 'raiseNextAccountIndex'. Raised for the profile running the scan: which
-- profile a recovered name belonged to is not recorded anywhere, so the only
-- safe reading is that every index found is taken.
raiseNextNameIndex :: DB.Connection -> User -> NameIndex -> IO ()
raiseNextNameIndex db User {userId} ix =
DB.execute
db
"UPDATE users SET wallet_next_name_index = ? WHERE user_id = ? AND wallet_next_name_index < ?"
(ix', userId, ix')
where
ix' = fromIntegral ix :: Int64
-- | Point the next purchase at an index the user named. Unlike
-- 'raiseNextNameIndex' this moves the mark either way: the user is placing a
-- name deliberately, and may be filling a gap left by a failed attempt.
setNextNameIndex :: DB.Connection -> User -> NameIndex -> IO ()
setNextNameIndex db User {userId} ix =
DB.execute db "UPDATE users SET wallet_next_name_index = ? WHERE user_id = ?" (fromIntegral ix :: Int64, userId)
-- | Whether a key at this path already owns a name. Checked before a purchase,
-- because 'recordNameKey' runs after the code has been spent: a clash caught by
-- the UNIQUE constraint there costs the user the code.
nameKeyPathTaken :: DB.Connection -> SeedId -> Text -> IO Bool
nameKeyPathTaken db (SeedId sId) path = do
r <-
maybeFirstRow fromOnly $
DB.query db "SELECT 1 FROM wallet_name_keys WHERE wallet_seed_id = ? AND derivation_path = ?" (sId, path)
pure $ isJust (r :: Maybe Int64)
-- | Record which key owns a name, once it is registered. Without this the
-- client cannot tell which of a profile's keys owns which name: the binding is
-- not on chain and is not derivable.
+22 -12
View File
@@ -220,7 +220,7 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te
<> map (\c -> plain $ " contact " <> c) contact
<> map (\c -> plain $ " channel " <> c) channel
<> [ plain $ " expires " <> tshow expiry,
plain $ " " <> tshow edits <> " of 10 relayed edits left"
plain $ " " <> tshow edits <> " relayed edits left"
]
CRNameLinkSet u n record txHash ->
ttyUser u [plain $ n <> ": " <> record <> " updated (tx " <> safeDecodeUtf8 (strEncode txHash) <> ")"]
@@ -228,18 +228,28 @@ chatResponseToView hu cfg@ChatConfig {logLevel, showReactions, showFullLinks, te
ttyUser u $ case found of
[] -> ["no names found for the keys on this device"]
_ -> "found:" : map (\(n, path) -> plain $ " " <> n <> " " <> path) found
-- Grouped by account: which profile a name belonged to is not recoverable from
-- the phrase, so after importing on a new device this listing is what tells
-- the user which account to pin a profile back to.
CRNameKeys u rows ->
ttyUser u $ case rows of
[] -> ["no recovery keys yet - one is created when you buy a name"]
_ ->
map
( \(i, ns, cur, backed) ->
plain $
tshow i <> ": " <> (if null ns then "no names yet" else T.intercalate ", " ns)
<> (if cur then " (in use)" else "")
<> (if backed then "" else " (not written down)")
)
rows
let name acct (n, path) = if isJust acct then n else n <> " (" <> path <> ")"
accountRow (acct, ns) =
plain $
" "
<> maybe "other " (\a -> "account " <> tshow a) acct
<> " "
<> T.intercalate ", " (map (name acct) ns)
keyRow (i, groups, cur, backed) =
plain
( tshow i
<> ":"
<> (if cur then " (in use)" else "")
<> (if backed then "" else " (not written down)")
)
: if null groups then [" no names yet"] else map accountRow groups
in ttyUser u $ case rows of
[] -> ["no recovery keys yet - one is created when you buy a name"]
_ -> concatMap keyRow rows
-- Every key, so that "I wrote down the phrase" cannot mean only one of them.
CRNameKeyPhrases u rows ->
ttyUser u $
+11
View File
@@ -43,6 +43,7 @@ module Simplex.Chat.Wallet
deriveAtPath,
nameKeyPath,
renderNameKeyPath,
parseNameKeyPath,
accountAddress,
signIntent,
ethSignatureBytes,
@@ -122,6 +123,16 @@ 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
-- | The account and name indices of a path we generated, or Nothing for one we
-- did not: an imported name sits at the root or under another wallet's layout,
-- and its indices are not ours to reason about.
parseNameKeyPath :: Text -> Maybe (AccountIndex, NameIndex)
parseNameKeyPath path = case B32.parsePath (encodeUtf8 path) of
Right [p, c, acc, 0, k]
| p == B32.hardened 44 && c == B32.hardened 60 && B32.isHardened acc ->
Just (acc - B32.hardenedOffset, k)
_ -> Nothing
-- | Derive the key that owns one name.
deriveNameKey :: WalletSeed -> AccountIndex -> NameIndex -> Either String WalletAccount
deriveNameKey s acc nm = do
+87 -5
View File
@@ -1,4 +1,6 @@
{-# LANGUAGE LambdaCase #-}
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE ScopedTypeVariables #-}
module Bots.NamesServiceTests where
@@ -10,6 +12,7 @@ import qualified Data.Aeson as J
import qualified Data.ByteString as B
import qualified Data.ByteString.Lazy.Char8 as LB
import Data.Char (isHexDigit)
import Control.Exception (ErrorCall, try)
import Data.List (isPrefixOf)
import Simplex.Chat.Names.Protocol
import Simplex.Messaging.Encoding.String (strEncode)
@@ -22,7 +25,7 @@ import Data.Text.Encoding (encodeUtf8)
import Data.Word (Word32)
import Simplex.Messaging.Util (tshow)
import qualified Simplex.Messaging.Crypto.BIP39 as B39
import Simplex.Chat.Wallet (SeedId (..), WalletSeed (..), accountAddress, deriveNameKey)
import Simplex.Chat.Wallet (SeedId (..), WalletSeed (..), accountAddress, deriveNameKey, parseNameKeyPath, renderNameKeyPath)
import qualified Test.Hspec as Hspec
namesServiceTests :: SpecWith TestParams
@@ -31,6 +34,7 @@ namesServiceTests = do
it "rejects a reveal with no matching commitment" testRevealWithoutCommit
it "reads the wallet without creating one" testNameAddress
it "gives each name its own key, still derivable after restart" testSeedPersists
it "recovers the derivation marks from an imported phrase" testRecoverMarks
it "buys with a code, then re-points the link with a signature" testBuyAndLink
it "refuses a spent code, a reserved name and a short name" testBuyRefusals
@@ -53,6 +57,21 @@ namesProtocolTests = do
addrOf 0 1 `shouldBe` "0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0"
-- Ledger Live account 2 for this phrase
addrOf 1 0 `shouldBe` "0x78839F6054d7ed13918bAe0473BA31b1Ca9D7265"
-- The account a name sits under is read back out of its stored path: that is
-- the only record of which profile owned it, and what the recovery scan reads
-- to know which indices are taken. A path we did not generate has no account
-- of ours and must say so rather than guess one.
it "reads back the indices of a path it generated, and only those" $ \_ -> do
parseNameKeyPath (renderNameKeyPath 0 0) `shouldBe` Just (0, 0)
parseNameKeyPath (renderNameKeyPath 2 7) `shouldBe` Just (2, 7)
-- a name bought in a dapp typically sits at the bare master key
parseNameKeyPath "m" `shouldBe` Nothing
-- neither of the two common wallet layouts the scan also probes is ours
parseNameKeyPath "m/44'/60'/0'/1" `shouldBe` Nothing
-- right shape, wrong coin
parseNameKeyPath "m/44'/0'/0'/0/0" `shouldBe` Nothing
-- the account level must be hardened, as BIP-44 requires
parseNameKeyPath "m/44'/60'/0/0/0" `shouldBe` Nothing
Hspec.it "encodes commit and reveal requests" $ do
-- on-chain byte values are 0x-prefixed hex, as Ethereum writes them
encodes (NamesRequest 1 (NRCommit $ Commitment "0123456789abcdef")) $
@@ -134,7 +153,69 @@ testNameAddress ps =
-- now it exists, and reports the address and path the name was registered to
-- and now exactly one key exists, controlling the name just bought
client ##> "/name keys"
client <## "1: carolname.simplex (in use) (not written down)"
client <## "1: (in use) (not written down)"
client <## " account 0 carolname.simplex"
-- | Recovery on a new device must not re-use a key a recovered name already
-- owns. Neither high-water mark survives an import - the phrase records only
-- the entropy - so a scan is the only thing that can restore them, and without
-- that the next purchase derives the recovered name's key: the client would
-- discover the clash from the UNIQUE constraint on wallet_name_keys, after the
-- registration had already gone through.
--
-- Asserted through the paths: the second name must not land on the first's.
testRecoverMarks :: HasCallStack => TestParams -> IO ()
testRecoverMarks ps = do
let opts = mkBadgeServiceOpts ps
withNewTestChatCfg ps testCfg serviceDbPrefix badgeProfile $ \_ -> pure ()
runBadgeService testCfg opts (pure ())
bsLink <- withTestChat ps serviceDbPrefix $ \bs -> do
bs <## "subscribed 1 connections on server localhost"
bs ##> "/sa"
(sLink, _) <- getContactLinks bs False
bs <## "auto_accept off"
pure sLink
runBadgeService testCfg opts $ do
-- device one buys a name, then its phrase is written down
phrase <- withNewTestChatCfg ps testCfg "client" bobProfile $ \client -> do
client ##> ("/name register " <> bsLink <> " lostname.simplex simplex:/contact#/x")
_ <- ownerOf client "lostname.simplex"
client ##> "/name keys export"
client <## "write these down - anyone who knows them can take the names they control:"
client <## "1: lostname.simplex"
phraseLine client
-- device two is fresh: import, scan, then buy
withNewTestChatCfg ps testCfg "client2" cathProfile $ \client -> do
client ##> ("/name keys import " <> phrase)
client <## "1: (in use) (not written down)"
client <## " no names yet"
client ##> ("/name rescan " <> bsLink)
awaitLine client "found:"
client <## " lostname.simplex m/44'/60'/0'/0/0"
client ##> ("/name register " <> bsLink <> " foundname.simplex simplex:/contact#/y")
_ <- ownerOf client "foundname.simplex"
-- the scan moved both marks past index 0, so the new name is elsewhere
client ##> "/name keys"
client <## "1: (in use) (not written down)"
client <## " account 0 lostname.simplex"
client <## " account 1 foundname.simplex"
-- | A scan is one round trip per candidate path, taken sequentially, and
-- outruns the harness's five second per-line read timeout. Retrying is not
-- polling: each attempt blocks on the output queue, so this waits as long as
-- the scan takes and no longer.
awaitLine :: HasCallStack => TestCC -> String -> Expectation
awaitLine cc expected = go (6 :: Int)
where
go 0 = expectationFailure $ "no line: " <> expected
go n =
try (getTermLine cc) >>= \case
Left (_ :: ErrorCall) -> go (n - 1)
Right l -> l `shouldBe` expected
-- | The phrase line printed under a key by @\/name keys export@.
phraseLine :: HasCallStack => TestCC -> IO String
phraseLine client = dropWhile (== ' ') <$> getTermLine client
-- | The front-running defence: a reveal only registers a name if that exact
-- commitment was published first. Sent as a raw service request, because the
@@ -181,7 +262,8 @@ testSeedPersists ps = do
-- and both are still derivable in a third session, each at its own path
withTestChat ps "client" $ \client -> do
client ##> "/name keys"
client <## "1: firstname.simplex, secondname.simplex (in use) (not written down)"
client <## "1: (in use) (not written down)"
client <## " account 0 firstname.simplex, secondname.simplex"
-- | Reads past startup and progress lines to the registration result, returning
-- the owner address. Keeps reading until the final progress event has arrived
@@ -223,7 +305,7 @@ testBuyAndLink ps =
client <## " path m/44'/60'/0'/0/0"
client <## " contact simplex:/contact#/first"
client <##. " expires "
client <## " 10 of 10 relayed edits left"
client <## " 10 relayed edits left"
-- a signed edit: the service recovers the signer and refuses anyone else
client ##> ("/name link " <> bsLink <> " contact purchased.simplex simplex:/contact#/second")
client <##. "purchased.simplex: contact updated (tx 0x"
@@ -234,7 +316,7 @@ testBuyAndLink ps =
client <## " contact simplex:/contact#/second"
client <##. " expires "
-- one edit spent, and only one
client <## " 9 of 10 relayed edits left"
client <## " 9 relayed edits left"
-- | Every refusal has its own message. A user who types a reserved name must be
-- told that, not "bad request".