mirror of
https://github.com/simplex-chat/simplex-chat.git
synced 2026-09-28 06:59:42 +00:00
names: derive one key per name at m/44'/60'/i'/0/k
This commit is contained in:
@@ -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}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user