names: derive one key per name at m/44'/60'/i'/0/k

This commit is contained in:
Alain Brenzikofer
2026-08-26 20:40:23 +02:00
parent 5fc7215f3b
commit 8a62fc49c1
13 changed files with 366 additions and 47 deletions
@@ -23,6 +23,75 @@ The client keeps no local names record.
chain, payment, stealth/gifting, GUIs, min-commitment-age enforcement,
tx-hash inclusion verification, anti-grief deposits.
## One key per name
A name is owned by a key derived at `m/44'/60'/i'/0/k` — the profile's BIP-44
account `i`, and the name at address index `k`. `k` is taken when the name is
registered, so a profile's second name lands on a different address.
```
seed (BIP-39)
└── profile account i
└── m/44'/60'/i'/0/k one key per name; k = 0 is the profile's first
```
Worked through, for a profile that buys two names and later imports a second
seed it had used in a dapp:
```
seed1 generated by the CLI
└── profile Alice = account 0
├── m/44'/60'/0'/0/0 0x69A6…2d32 owns alice.simplex
└── m/44'/60'/0'/0/1 0x4C1f…9Ab7 owns lizzy.simplex
seed2 imported later
└── m 0x1D07…4bE9 owns lucy.simplex (root, no derivation)
```
Nothing here is a custom layout: `account` and `address_index` are what BIP-44
has those levels for, and the addresses line up with wallets users already have.
Pinned in the tests 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 exactly MetaMask's account list, in order**, and each
profile's first name is the matching Ledger Live account. Moving a single name
between this wallet and another one is therefore a derivation question already
answered — but the commands to do it (recovery-phrase import, single-name key
export) are follow-up work, not in this PR.
**Why not one key per profile.** Exporting it would hand over every name that
profile owns, and `SimplexResolver` keeps one nonce per signer shared across
every node it owns — so a shared key would serialise every name's record edits
behind one counter. Both go away with an index per name.
**Which key owns which name is not on chain and not derivable**, so it is
recorded locally (`wallet_name_keys`). The path is stored literally rather than
as indices, because a name found on an imported seed may sit on a layout that is
not ours — `lucy.simplex` above stays at the root and is re-derived from `"m"`.
### Where stealth addresses will attach
Not in this PR, but the layout has to leave room. A profile publishes **one
meta-address**: a spend key and a viewing key, both hardened under purpose
`5564'` at the profile level, `m/5564'/60'/i'/0'/0` and `m/5564'/60'/i'/1'/0`.
A sender derives a fresh destination from it without any handshake — shared
secret `s = keccak256(r · P_view)` for a random ephemeral `r`, destination
`addr(P_spend + s·G)` — and the recipient recomputes `s` from the sender's
ephemeral public key `R` as `keccak256(p_view · R)`, holding the name with
`p_spend + s`.
So a received name's key is **not at a derivation path**: it is the spend key
plus a scalar, recoverable from `R` rather than from an index. One meta-address
per profile therefore serves any number of received names, which is why the
meta-address sits at the profile level while owned names sit at the address
level.
## Why two RPCs for one call
`purchaseName` splits into **commit** then **reveal** so the registrar cannot
@@ -146,8 +215,8 @@ a user registering the same name again.
| Component | This PR | Extends to |
|---|---|---|
| **Wallet** | `Wallet.newSeed` + `deriveAccount` + `accountAddress`, already built. No signing. | `signIntent` (also built) unlocks edits/transfers with no shape change. |
| **Wallet storage** | the prototype's `wallet_seeds` migration and `Store.Wallets` verbatim: one seed per DB, one account index per profile, allocated from a stored high-water mark. | recovery import, `backed_up` reminder and the one-time-address table are already in the schema; they need code only. |
| **Wallet** | `Wallet.newSeed` + `deriveNameKey` / `deriveAtPath` + `accountAddress`. No signing. | `signIntent` (also built) unlocks edits/transfers with no shape change. |
| **Wallet storage** | `wallet_seeds` plus `wallet_name_keys`: one account index per profile, one address index per name, both from stored high-water marks. | recovery import, `backed_up` reminder and the one-time-address table are already in the schema; they need code only. |
| **RPC transport** | badges' `APISendServiceRequest` / `APISendServiceResponse`, unchanged. | shared. |
| **Service** | extend `BadgeService/Service.hs` `handleServiceRequest` to dispatch `NRCommit`/`NRReveal`; add an in-memory chain mock (a `TVar (Map name entry)`, like `Names.Service.Mock`). | swap the mock for a relayer to a deployed SNRC. |
| **Resolution** | the mock chain is the record: the name resolves there to owner and SimpleX link. No client-side names store. | a resolver read against a deployed SNRC; add a local cache only if a listing UX needs it. |
+2
View File
@@ -162,6 +162,7 @@ library
Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection
Simplex.Chat.Store.Postgres.Migrations.M20260731_user_badges
Simplex.Chat.Store.Postgres.Migrations.M20260818_wallet_seeds
Simplex.Chat.Store.Postgres.Migrations.M20260826_wallet_name_keys
else
exposed-modules:
Simplex.Chat.Archive
@@ -334,6 +335,7 @@ library
Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection
Simplex.Chat.Store.SQLite.Migrations.M20260731_user_badges
Simplex.Chat.Store.SQLite.Migrations.M20260818_wallet_seeds
Simplex.Chat.Store.SQLite.Migrations.M20260826_wallet_name_keys
other-modules:
Paths_simplex_chat
hs-source-dirs:
+3 -3
View File
@@ -842,8 +842,8 @@ data ChatResponse
| CRContactRequestRejected {user :: User, contactRequest :: UserContactRequest, contact_ :: Maybe Contact}
| CRServiceResponse {user :: User, responseData :: J.Object}
| CRServiceReplyAccepted {user :: User, connectionId :: AgentConnId}
| CRNameRegistered {user :: User, regName :: Text, regOwner :: Text, regExpiry :: UTCTime, regTxHash :: TxHash}
| CRNameAddress {user :: User, nameAddress :: Maybe Text}
| CRNameRegistered {user :: User, regName :: Text, regOwner :: Text, regPath :: Text, regExpiry :: UTCTime, regTxHash :: TxHash}
| CRNameAddress {user :: User, nameAddresses :: [(Text, Text, 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]}
@@ -1475,7 +1475,7 @@ data ChatErrorType
| CESimplexDomainNotReady {simplexDomain :: SimplexDomain, simplexDomainError :: SimplexDomainError}
-- | the names service refused the registration; @nameRegCode@ is the protocol
-- error code as sent on the wire, e.g. @name_taken@
| CENameRegistrationFailed {nameRegCode :: Text, nameRegMessage :: Maybe Text}
| CENameRegistrationFailed {nameRegCode :: Text, nameRegMessage :: Maybe Text, nameRegRetryAfter :: Maybe Word32}
| 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}
+27 -9
View File
@@ -60,8 +60,8 @@ import Simplex.Chat.Library.Subscriber
import Simplex.Chat.Badges (BadgeCredential (..), LocalBadge (..), maxXFTPFileSize, mkBadgeStatus, verifyCredential)
import Simplex.Chat.Names (SimplexDomainProof (..), SimplexDomainClaim (..), claimDomain, mkDomainClaim)
import Simplex.Chat.Names.Protocol
import Simplex.Chat.Store.Wallets (boundAccount, getOrCreateAccountRef)
import Simplex.Chat.Wallet (AccountRef (..), accountAddress, deriveAccount, newSeed)
import Simplex.Chat.Store.Wallets (boundAccount, getNameKeys, getOrCreateAccountRef, recordNameKey, takeNameIndex)
import Simplex.Chat.Wallet (AccountRef (..), WalletSeed (..), accountAddress, deriveAtPath, deriveNameKey, newSeed, renderNameKeyPath)
import Simplex.Chat.Call
import Simplex.Chat.Controller
import Simplex.Chat.Delivery (DeliveryJobScope (..), DeliveryJobSpec (..), DeliveryWorkerScope (..))
@@ -1465,7 +1465,7 @@ processChatCommand cxt nm = \case
resp <- either (const $ throwCmdError "invalid service response") pure $ J.eitherDecodeStrict' respData
pure $ CRServiceResponse user resp
APINameRegister sendTarget nm' sLink -> withUser $ \user -> do
owner <- deriveNameOwner user
(seedId, nameIx, acctIx, owner) <- deriveNameOwner user
cReq <- resolveServiceTarget nm user sendTarget
g <- asks random
secret <- NameSecret <$> atomically (C.randomBytes 32 g)
@@ -1477,7 +1477,7 @@ processChatCommand cxt nm = \case
let req = NamesRequest currentNamesVersion c
respData <- withAgent $ \a -> sendServiceRequestAsync a (aUserId user) cReq Nothing Nothing (LB.toStrict $ J.encode req)
either (const $ throwCmdError "invalid names response") pure (J.eitherDecodeStrict' respData) >>= \case
NRPError {nrCode, nrMessage} -> throwChatError $ CENameRegistrationFailed (textEncode nrCode) nrMessage
NRPError {nrCode, nrMessage, nrRetryAfter} -> throwChatError $ CENameRegistrationFailed (textEncode nrCode) nrMessage nrRetryAfter
r -> pure r
progress phase waitMs = toView $ CEvtNameRegistrationProgress user nm' phase waitMs
progress NRPhaseCommitting Nothing
@@ -1492,22 +1492,40 @@ processChatCommand cxt nm = \case
NRPRegistered {nrExpiry, nrTxHash} -> pure (nrExpiry, nrTxHash)
_ -> throwCmdError "unexpected reveal response"
progress NRPhaseRegistered Nothing
pure $ CRNameRegistered user nm' (tshow owner) expiry' txHash'
-- 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'
where
-- 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 registration by
-- the same profile lands on a different address rather than sharing one.
deriveNameOwner user = do
g <- asks random
(seed, AccountRef {arIndex}) <-
withFastStore' $ \db -> getOrCreateAccountRef db user (atomically $ newSeed MS256 g)
either (throwCmdError . ("wallet: " <>)) (pure . accountAddress) $ deriveAccount seed arIndex
nameIx <- withFastStore' $ \db -> takeNameIndex db user
acc <- either (throwCmdError . ("wallet: " <>)) pure $ deriveNameKey seed arIndex nameIx
pure (wsId seed, nameIx, arIndex, accountAddress acc)
APINameAddress -> withUser $ \user -> do
-- Read-only: never creates a seed. A key appears when you register a name,
-- not when you ask which address you have.
--
-- There is no single "profile address" any more: each name has its own key,
-- so this answers with one row per name.
acc_ <- withFastStore' $ \db -> boundAccount db user
addr <- forM acc_ $ \(seed, AccountRef {arIndex}) ->
either (throwCmdError . ("wallet: " <>)) (pure . tshow . accountAddress) $ deriveAccount seed arIndex
pure $ CRNameAddress user addr
addrs <- case acc_ of
Nothing -> pure []
Just (seed, AccountRef {arIndex}) -> do
named <- withFastStore' $ \db -> getNameKeys db (wsId seed)
forM named $ \(nm_, path) -> do
acc <- either (throwCmdError . ("wallet: " <>)) pure $ deriveAtPath seed arIndex path
pure (nm_, tshow (accountAddress acc), path)
pure $ CRNameAddress user addrs
APISendServiceResponse userId requestId responseData -> withUserId userId $ \user -> do
let AgentInvId invId = requestId
connId <- withAgent $ \a -> sendServiceReplyAsync a "" (aUserId user) invId (LB.toStrict $ J.encode responseData)
@@ -47,6 +47,7 @@ import Simplex.Chat.Store.Postgres.Migrations.M20260716_signed_history
import Simplex.Chat.Store.Postgres.Migrations.M20260720_server_roles
import Simplex.Chat.Store.Postgres.Migrations.M20260723_contact_request_rejection
import Simplex.Chat.Store.Postgres.Migrations.M20260818_wallet_seeds
import Simplex.Chat.Store.Postgres.Migrations.M20260826_wallet_name_keys
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Text, Maybe Text)]
@@ -93,7 +94,8 @@ schemaMigrations =
("20260716_signed_history", m20260716_signed_history, Just down_m20260716_signed_history),
("20260720_server_roles", m20260720_server_roles, Just down_m20260720_server_roles),
("20260723_contact_request_rejection", m20260723_contact_request_rejection, Just down_m20260723_contact_request_rejection),
("20260818_wallet_seeds", m20260818_wallet_seeds, Just down_m20260818_wallet_seeds)
("20260818_wallet_seeds", m20260818_wallet_seeds, Just down_m20260818_wallet_seeds),
("20260826_wallet_name_keys", m20260826_wallet_name_keys, Just down_m20260826_wallet_name_keys)
]
-- | The list of migrations in ascending order by date
@@ -0,0 +1,35 @@
{-# LANGUAGE OverloadedStrings #-}
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.Postgres.Migrations.M20260826_wallet_name_keys where
import Data.Text (Text)
import Text.RawString.QQ (r)
m20260826_wallet_name_keys :: Text
m20260826_wallet_name_keys =
[r|
CREATE TABLE wallet_name_keys (
wallet_name_key_id BIGINT GENERATED ALWAYS AS IDENTITY PRIMARY KEY,
wallet_seed_id BIGINT NOT NULL REFERENCES wallet_seeds ON DELETE RESTRICT,
derivation_path TEXT NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
UNIQUE (wallet_seed_id, derivation_path),
UNIQUE (wallet_seed_id, name)
);
CREATE INDEX idx_wallet_name_keys_wallet_seed_id ON wallet_name_keys(wallet_seed_id);
ALTER TABLE users ADD COLUMN wallet_next_name_index BIGINT NOT NULL DEFAULT 0;
|]
down_m20260826_wallet_name_keys :: Text
down_m20260826_wallet_name_keys =
[r|
ALTER TABLE users DROP COLUMN wallet_next_name_index;
DROP INDEX idx_wallet_name_keys_wallet_seed_id;
DROP TABLE wallet_name_keys;
|]
@@ -170,6 +170,7 @@ import Simplex.Chat.Store.SQLite.Migrations.M20260716_signed_history
import Simplex.Chat.Store.SQLite.Migrations.M20260720_server_roles
import Simplex.Chat.Store.SQLite.Migrations.M20260723_contact_request_rejection
import Simplex.Chat.Store.SQLite.Migrations.M20260818_wallet_seeds
import Simplex.Chat.Store.SQLite.Migrations.M20260826_wallet_name_keys
import Simplex.Messaging.Agent.Store.Shared (Migration (..))
schemaMigrations :: [(String, Query, Maybe Query)]
@@ -328,6 +329,7 @@ schemaMigrations =
("20260516_supporter_badges", m20260516_supporter_badges, Just down_m20260516_supporter_badges),
("20260818_wallet_seeds", m20260818_wallet_seeds, Just down_m20260818_wallet_seeds),
("20260529_delivery_job_senders", m20260529_delivery_job_senders, Just down_m20260529_delivery_job_senders),
("20260826_wallet_name_keys", m20260826_wallet_name_keys, Just down_m20260826_wallet_name_keys),
("20260530_client_services", m20260530_client_services, Just down_m20260530_client_services),
("20260531_member_removed_at", m20260531_member_removed_at, Just down_m20260531_member_removed_at),
("20260601_relay_sent_web_domain", m20260601_relay_sent_web_domain, Just down_m20260601_relay_sent_web_domain),
@@ -0,0 +1,43 @@
{-# LANGUAGE QuasiQuotes #-}
module Simplex.Chat.Store.SQLite.Migrations.M20260826_wallet_name_keys where
import Database.SQLite.Simple (Query)
import Database.SQLite.Simple.QQ (sql)
-- | One key per name, at BIP-44 address index k under the profile's account.
--
-- Which key owns which name is recorded nowhere else: it is not on chain, and a
-- name found by a future recovery scan may sit on a layout that is not ours (a
-- name bought in a dapp is typically at the master key, with no derivation at
-- all). So the path is stored literally rather than as indices.
m20260826_wallet_name_keys :: Query
m20260826_wallet_name_keys =
[sql|
CREATE TABLE wallet_name_keys (
wallet_name_key_id INTEGER PRIMARY KEY AUTOINCREMENT,
wallet_seed_id INTEGER NOT NULL REFERENCES wallet_seeds ON DELETE RESTRICT,
derivation_path TEXT NOT NULL, -- "m/44'/60'/0'/0/1", or "m" for a root key
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
UNIQUE (wallet_seed_id, derivation_path),
UNIQUE (wallet_seed_id, name)
) STRICT;
-- required: the .lint check enforces an index on every foreign key
CREATE INDEX idx_wallet_name_keys_wallet_seed_id ON wallet_name_keys(wallet_seed_id);
-- High-water mark for k, per profile. Same reason as next_account_index: after
-- recovery from the phrase alone nothing else knows which indices are taken.
ALTER TABLE users ADD COLUMN wallet_next_name_index INTEGER NOT NULL DEFAULT 0;
|]
down_m20260826_wallet_name_keys :: Query
down_m20260826_wallet_name_keys =
[sql|
ALTER TABLE users DROP COLUMN wallet_next_name_index;
DROP INDEX idx_wallet_name_keys_wallet_seed_id;
DROP TABLE wallet_name_keys;
|]
@@ -55,7 +55,8 @@ CREATE TABLE users(
is_user_chat_relay INTEGER NOT NULL DEFAULT 0,
client_service INTEGER NOT NULL DEFAULT 0,
wallet_seed_id INTEGER REFERENCES wallet_seeds ON DELETE RESTRICT,
wallet_account_index INTEGER, -- 1 for active user
wallet_account_index INTEGER,
wallet_next_name_index 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
@@ -856,6 +857,15 @@ CREATE TABLE wallet_seeds(
-- profile would silently reuse a recovered account's keys.
next_account_index INTEGER NOT NULL DEFAULT 0
) STRICT;
CREATE TABLE wallet_name_keys(
wallet_name_key_id INTEGER PRIMARY KEY AUTOINCREMENT,
wallet_seed_id INTEGER NOT NULL REFERENCES wallet_seeds ON DELETE RESTRICT,
derivation_path TEXT NOT NULL, -- "m/44'/60'/0'/0/1", or "m" for a root key
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT(datetime('now')),
UNIQUE(wallet_seed_id, derivation_path),
UNIQUE(wallet_seed_id, name)
) STRICT;
CREATE INDEX contact_profiles_index ON contact_profiles(
display_name,
full_name
@@ -1391,6 +1401,9 @@ 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 INDEX idx_wallet_name_keys_wallet_seed_id ON wallet_name_keys(
wallet_seed_id
);
CREATE TRIGGER on_group_members_insert_update_summary
AFTER INSERT ON group_members
FOR EACH ROW
+39 -1
View File
@@ -13,15 +13,19 @@
module Simplex.Chat.Store.Wallets
( getOrCreateAccountRef,
boundAccount,
takeNameIndex,
recordNameKey,
getNameKeys,
)
where
import Data.ByteString (ByteString)
import Data.Int (Int64)
import Data.Maybe (listToMaybe)
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, AccountRef (..), NameIndex, SeedId (..), WalletSeed (..))
import Simplex.Messaging.Agent.Store.AgentStore (maybeFirstRow)
import qualified Simplex.Messaging.Agent.Store.DB as DB
@@ -123,3 +127,37 @@ getNextAccountIndex db (SeedId sId) =
<$> ( maybeFirstRow fromOnly $
DB.query db "SELECT next_account_index FROM wallet_seeds WHERE wallet_seed_id = ?" (Only sId)
)
-- | Take the next name index for this profile and advance its high-water mark.
--
-- Names are BIP-44 address indices under the profile's account, so this counter
-- is per profile, not per seed. Stored for the same reason as
-- 'next_account_index': after recovery from the phrase alone nothing else knows
-- which indices already own names on chain.
takeNameIndex :: DB.Connection -> User -> IO NameIndex
takeNameIndex db User {userId} = do
ix <-
maybe 0 (fromIntegral :: Int64 -> NameIndex)
<$> ( maybeFirstRow fromOnly $
DB.query db "SELECT wallet_next_name_index FROM users WHERE user_id = ?" (Only userId)
)
DB.execute db "UPDATE users SET wallet_next_name_index = ? WHERE user_id = ?" (fromIntegral ix + 1 :: Int64, userId)
pure ix
-- | 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.
recordNameKey :: DB.Connection -> SeedId -> Text -> Text -> IO ()
recordNameKey db (SeedId sId) path name =
DB.execute
db
"INSERT INTO wallet_name_keys (wallet_seed_id, derivation_path, name) VALUES (?, ?, ?)"
(sId, path, name)
-- | Every name this seed owns, with the path its key was derived at.
getNameKeys :: DB.Connection -> SeedId -> IO [(Text, Text)]
getNameKeys db (SeedId sId) =
DB.query
db
"SELECT name, derivation_path FROM wallet_name_keys WHERE wallet_seed_id = ? ORDER BY wallet_name_key_id"
(Only sId)
+15 -5
View File
@@ -190,10 +190,15 @@ 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)]
CRNameAddress u addr_ ->
ttyUser u [maybe "no name address yet - it is created when you register a name" (plain . ("name address: " <>)) addr_]
CRNameRegistered u nm owner expiry txHash ->
ttyUser u [plain $ "name registered: " <> nm <> " -> " <> owner <> " (expires " <> tshow expiry <> ", tx " <> safeDecodeUtf8 (strEncode txHash) <> ")"]
CRNameAddress u addrs ->
ttyUser u $ case addrs of
[] -> ["no name addresses yet - one is created for each name you register"]
as -> "name addresses:" : map (\(nm, addr, path) -> plain $ " " <> nm <> " -> " <> addr <> " " <> path) as
CRNameRegistered u nm owner path expiry txHash ->
ttyUser u
[ plain $ "name registered: " <> nm <> " -> " <> owner <> " (expires " <> tshow expiry <> ", tx " <> safeDecodeUtf8 (strEncode txHash) <> ")",
plain $ " derivation path: " <> path
]
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
@@ -2808,7 +2813,12 @@ viewChatError isCmd logLevel testView = \case
CEAgentNoSubResult connId -> ["no subscription result for connection: " <> sShow connId]
CEServerProtocol p -> [plain $ "Servers for protocol " <> strEncode p <> " cannot be configured by the users"]
CECommandError e -> ["bad chat command: " <> plain e]
CENameRegistrationFailed code msg -> ["name registration failed: " <> plain code <> maybe "" ((": " <>) . plain) msg]
CENameRegistrationFailed code msg retryAfter ->
[ "name registration failed: "
<> plain code
<> maybe "" ((": " <>) . plain) msg
<> maybe "" (\s -> plain (" (retry after " <> tshow s <> "s)")) retryAfter
]
CEAgentCommandError e -> ["agent command error: " <> plain e]
CEInvalidFileDescription e -> ["invalid file description: " <> plain e]
CEConnectionIncognitoChangeProhibited -> ["incognito mode change prohibited"]
+65 -13
View File
@@ -1,13 +1,20 @@
{-# LANGUAGE OverloadedStrings #-}
-- | The wallet: BIP-39 seeds, and the per-chat-profile accounts derived from
-- them.
-- | The wallet: BIP-39 seeds, and the keys derived from them.
--
-- * __seed__ — BIP-39 entropy. Generic and profile-scoped, /not/ name-specific.
-- * __account__ — a profile's slot in a seed, index @i@, holding the main
-- address that owns the names the profile registers.
-- * __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 bought 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.
--
-- Names are a /consumer/ of the wallet, which is why this sits here rather than
-- under "Simplex.Chat.Names".
--
@@ -23,10 +30,14 @@ module Simplex.Chat.Wallet
( SeedId (..),
WalletSeed (..),
AccountIndex,
NameIndex,
AccountRef (..),
WalletAccount (..),
newSeed,
deriveAccount,
deriveNameKey,
deriveAtPath,
nameKeyPath,
renderNameKeyPath,
accountAddress,
)
where
@@ -35,11 +46,13 @@ 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, encodeUtf8)
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, ethereumPath)
import Simplex.Messaging.Eth.Address (Address, addressFromPrivateKey)
newtype SeedId = SeedId Int64
deriving (Eq, Ord, Show)
@@ -47,6 +60,9 @@ newtype SeedId = SeedId Int64
-- | 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.
--
@@ -83,14 +99,50 @@ instance Show WalletAccount where
newSeed :: B39.MnemonicStrength -> TVar ChaChaDRG -> STM ByteString
newSeed strength g = B39.mnemonicToEntropy <$> B39.randomMnemonic strength g
-- | Derive the account at @m\/44'\/60'\/i'\/0\/0@.
deriveAccount :: WalletSeed -> AccountIndex -> Either String WalletAccount
deriveAccount s ix = do
-- | @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 (ethereumPath ix)
pure WalletAccount {waRef = AccountRef {arSeedId = wsId s, arIndex = ix}, waKey = B32.xkKey xk}
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 names registered by this account.
-- | Derive at a path given literally, e.g. @"m\/44'\/60'\/0'\/0\/1"@ or @"m"@
-- for the master key with no derivation.
--
-- Names record the path they were derived at rather than an index, because a
-- name found on an imported seed may sit on a layout that is not ours — a name
-- bought in a dapp is typically at the master key. Re-deriving from the stored
-- path keeps those usable without special-casing them.
deriveAtPath :: WalletSeed -> AccountIndex -> Text -> Either String WalletAccount
deriveAtPath s acc path = do
m <- B39.entropyToMnemonic (wsEntropy s)
master <- B32.masterKey (B39.mnemonicToSeed m "")
ixs <- B32.parsePath (encodeUtf8 path)
xk <- B32.derivePath master ixs
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
-- Stealth addresses, when they arrive, hang off the same profile account but
-- are not at a derivation path at all. A profile publishes one meta-address —
-- a spend key and a viewing key, both hardened under purpose 5564' — and a
-- sender derives a fresh destination from it as @spend + H(r·view)·G@. The
-- recipient's key for that destination is @spend + H(view·R)@, recomputed from
-- the sender's ephemeral public key @R@ rather than from an index. So the
-- wallet must be able to hold a key that is "spend key plus a scalar", which is
-- why one meta-address per profile is enough for any number of received names.
+47 -12
View File
@@ -16,6 +16,8 @@ import Simplex.Messaging.Encoding.String (strEncode)
import Simplex.Messaging.Eth.Address (parseAddress)
import Simplex.Messaging.Eth.Keccak (keccak256)
import Test.Hspec hiding (it)
import qualified Simplex.Messaging.Crypto.BIP39 as B39
import Simplex.Chat.Wallet (SeedId (..), WalletSeed (..), accountAddress, deriveNameKey)
import qualified Test.Hspec as Hspec
namesServiceTests :: SpecWith TestParams
@@ -23,12 +25,27 @@ namesServiceTests = do
it "registers a name via commit/reveal and rejects a taken name" testNamesRegister
it "rejects a reveal with no matching commitment" testRevealWithoutCommit
it "shows the owner address without creating one" testNameAddress
it "derives the same owner address after restart" testSeedPersists
it "gives each name its own key, still derivable after restart" testSeedPersists
-- | Pins the wire format. The end-to-end test cannot catch a key renamed on
-- both sides at once, so the encodings are asserted literally here.
namesProtocolTests :: Spec
namesProtocolTests = do
-- Name keys are plain BIP-44, so they line up with wallets users already have.
-- Pinned against the standard test mnemonic: 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, and a name bought in a dapp be
-- found here.
it "name keys line up with other wallets' derivation" $ \_ -> do
let mn = either error id $ B39.parseMnemonic "abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon abandon about"
sd = WalletSeed {wsId = SeedId 1, wsEntropy = B39.mnemonicToEntropy mn}
addrOf i k = either error (show . accountAddress) (deriveNameKey sd i k)
-- MetaMask account 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 "encodes commit and reveal requests" $ do
-- on-chain byte values are 0x-prefixed hex, as Ethereum writes them
encodes (NamesRequest 1 (NRCommit $ Commitment "0123456789abcdef")) $
@@ -75,7 +92,9 @@ testNamesRegister ps =
-- the final progress event and the command response arrive on separate channels
client
<### [ ConsoleString "name alice.simplex: registered",
StartsWith "name registered: alice.simplex -> 0x"
StartsWith "name registered: alice.simplex -> 0x",
-- one key per name: the profile's first name is address index 0
ConsoleString " derivation path: m/44'/60'/0'/0/0"
]
-- re-running the identical command is rejected too, not silently accepted:
-- the owner is the same within a session, so this is the duplicate a user hits.
@@ -99,14 +118,15 @@ testNameAddress ps =
withBadgeService ps $ \client bsLink -> do
-- asked repeatedly before any registration: still no address, none created
client ##> "/name address"
client <## "no name address yet - it is created when you register a name"
client <## "no name addresses yet - one is created for each name you register"
client ##> "/name address"
client <## "no name address yet - it is created when you register a name"
client <## "no name addresses yet - one is created for each name you register"
client ##> ("/name register " <> bsLink <> " carol.simplex simplex:/contact#/x")
owner <- ownerOf client "carol.simplex"
-- now it exists, and reports the address the name was registered to
-- now it exists, and reports the address and path the name was registered to
client ##> "/name address"
client <## ("name address: " <> owner)
client <## "name addresses:"
client <## (" carol.simplex -> " <> owner <> " m/44'/60'/0'/0/0")
-- | 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
@@ -124,6 +144,12 @@ testRevealWithoutCommit ps =
-- | The seed is persisted, so a name registered in one session is still owned by
-- an address the next session can derive. Without this the key is unrecoverable
-- after restart and the name is orphaned.
--
-- It also pins the other half of one-key-per-name: the second registration must
-- land on a /different/ address, at the next BIP-44 address index under the same
-- profile account. Sharing one key across a profile's names is what this
-- replaces — it would put every name behind one resolver nonce and make
-- exporting one name's key hand over all of them.
testSeedPersists :: HasCallStack => TestParams -> IO ()
testSeedPersists ps = do
let opts = mkBadgeServiceOpts ps
@@ -143,20 +169,29 @@ testSeedPersists ps = do
owner2 <- withTestChat ps "client" $ \client -> do
client ##> ("/name register " <> bsLink <> " second.simplex simplex:/contact#/y")
ownerOf client "second.simplex"
owner2 `shouldBe` owner1
owner2 `shouldNotBe` owner1
-- and both are still derivable in a third session, each at its own path
withTestChat ps "client" $ \client -> do
client ##> "/name address"
client <## "name addresses:"
client <## (" first.simplex -> " <> owner1 <> " m/44'/60'/0'/0/0")
client <## (" second.simplex -> " <> owner2 <> " m/44'/60'/0'/0/1")
-- | Reads past startup and progress lines to the registration result, returning
-- the owner address. Keeps reading until the final progress event has arrived
-- too — it races with the command response and would otherwise be left
-- unconsumed, failing the next assertion or the session close.
ownerOf :: HasCallStack => TestCC -> String -> IO String
ownerOf client nm = go (40 :: Int) Nothing False
ownerOf client nm = go (40 :: Int) Nothing False False
where
pfx = "name registered: " <> nm <> " -> "
pathLine = " derivation path: "
lastEvt = "name " <> nm <> ": registered"
go _ (Just a) True = pure a
go 0 _ _ = error $ "no registration line for " <> nm
go n addr seen = do
-- three lines must be consumed before the next assertion: the progress
-- event, the registration line, and the derivation path under it
go _ (Just a) True True = pure a
go 0 _ _ _ = error $ "no registration line for " <> nm
go n addr seen path = do
l <- getTermLine client
let addr' = if pfx `isPrefixOf` l then Just (takeWhile (/= ' ') $ drop (length pfx) l) else addr
go (n - 1) addr' (seen || l == lastEvt)
go (n - 1) addr' (seen || l == lastEvt) (path || pathLine `isPrefixOf` l)