From 443fd435450841c09fbea6ac2aefa3a58cde3bf0 Mon Sep 17 00:00:00 2001 From: Alain Brenzikofer Date: Fri, 11 Sep 2026 12:28:46 +0000 Subject: [PATCH] core: one command for the seed, whichever way it arrives create and import differed only in where the entropy came from and already shared one store function, so they are one command with the source named: /_wallet create new, or /_wallet create seed=. Naming it means no seed is generated by typing a prefix. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Rvc3HbiWBTqbAvRT45G5oX --- bots/src/API/Docs/Commands.hs | 1 - docs/rfcs/2026-09-10-wallet-keys.md | 21 ++++++++++++--------- src/Simplex/Chat/Controller.hs | 6 ++---- src/Simplex/Chat/Library/Commands.hs | 16 ++++++---------- tests/WalletTests.hs | 24 ++++++++++++------------ 5 files changed, 32 insertions(+), 36 deletions(-) diff --git a/bots/src/API/Docs/Commands.hs b/bots/src/API/Docs/Commands.hs index 0be6b110a7..8ac5f43238 100644 --- a/bots/src/API/Docs/Commands.hs +++ b/bots/src/API/Docs/Commands.hs @@ -455,7 +455,6 @@ undocumentedCommands = "APIWalletDelete", "APIWalletExportDerivedSecret", "APIWalletExportSeedMnemonic", - "APIWalletImport", "CheckChatRunning", "ConfirmRemoteCtrl", "ConnectRemoteCtrl", diff --git a/docs/rfcs/2026-09-10-wallet-keys.md b/docs/rfcs/2026-09-10-wallet-keys.md index a8221644eb..123bfb568c 100644 --- a/docs/rfcs/2026-09-10-wallet-keys.md +++ b/docs/rfcs/2026-09-10-wallet-keys.md @@ -66,17 +66,20 @@ at the profile level, while what a profile buys sits at the address level. Internal API. The names commands will call these; users will not. ``` -/_wallet the next name addresses -/_wallet create generate the seed. Refused if the device has one -/_wallet import store a seed. Refused if the device has one -/_wallet export the seed mnemonic -/_wallet export one derived secret, as 0x and 64 hex digits -/_wallet delete delete the seed +/_wallet the next name addresses +/_wallet create new generate the seed +/_wallet create seed= take the seed from a phrase +/_wallet export the seed mnemonic +/_wallet export one derived secret, as 0x and 64 hex digits +/_wallet delete delete the seed ``` -A seed is created only when asked for, never at startup and never as a side -effect of reading. None of these is forwarded to a remote host: the recovery -phrase must not leave the device, and the raw command would be logged there. +Creating and importing are one command, because they differ only in where the +entropy comes from, and either is refused if the device already has a seed. The +source is always named, so no seed is generated by typing a prefix. A seed is +created only when asked for, never at startup and never as a side effect of +reading. None of these is forwarded to a remote host: the recovery phrase must +not leave the device, and the raw command would be logged there. ## Schema diff --git a/src/Simplex/Chat/Controller.hs b/src/Simplex/Chat/Controller.hs index ae3ca95029..1adbfa3498 100644 --- a/src/Simplex/Chat/Controller.hs +++ b/src/Simplex/Chat/Controller.hs @@ -418,8 +418,7 @@ data ChatCommand | APISendServiceRequest {userId :: UserId, sendTarget :: ConnectTarget 'CMContact, requestTimeout :: Maybe NominalDiffTime, signKey :: Maybe (C.StoredPrivateKey 'C.Ed25519), request :: J.Object} | APISendServiceResponse {userId :: UserId, requestId :: AgentInvId, responseData :: J.Object} | APIWallet - | APIWalletCreate - | APIWalletImport {recoveryPhrase :: Text} + | APIWalletCreate {recoveryPhrase :: Maybe Text} | APIWalletExportSeedMnemonic | APIWalletExportDerivedSecret {nameIndex :: NameIndex} | APIWalletDelete @@ -749,8 +748,7 @@ allowRemoteCommand = \case ExecChatStoreSQL _ -> False ExecAgentStoreSQL _ -> False APIWallet -> False - APIWalletCreate -> False - APIWalletImport _ -> False + APIWalletCreate {} -> False APIWalletExportSeedMnemonic -> False APIWalletExportDerivedSecret {} -> False APIWalletDelete -> False diff --git a/src/Simplex/Chat/Library/Commands.hs b/src/Simplex/Chat/Library/Commands.hs index 9bb67cc955..b336aca007 100644 --- a/src/Simplex/Chat/Library/Commands.hs +++ b/src/Simplex/Chat/Library/Commands.hs @@ -1498,14 +1498,10 @@ processChatCommand cxt nm = \case Just seed -> do next <- withFastStore' $ \db -> getNextNameIndex db (wsId seed) CRWallet user True <$> nameKeyRows seed next - APIWalletCreate -> withUser $ \_ -> do - g <- asks random - entropy <- atomically $ newSeed MS256 g - created <- withFastStore' $ \db -> createSeed db entropy - unless created $ throwCmdError "this device already has a wallet key" - processChatCommand cxt nm APIWallet - APIWalletImport phrase -> withUser $ \_ -> do - entropy <- either (const $ throwCmdError "bad recovery phrase") pure $ importRecoveryKey (encodeUtf8 phrase) + APIWalletCreate phrase_ -> withUser $ \_ -> do + entropy <- case phrase_ of + Nothing -> asks random >>= atomically . newSeed MS256 + Just phrase -> either (const $ throwCmdError "bad recovery phrase") pure $ importRecoveryKey (encodeUtf8 phrase) created <- withFastStore' $ \db -> createSeed db entropy unless created $ throwCmdError "this device already has a wallet key" processChatCommand cxt nm APIWallet @@ -5587,8 +5583,8 @@ chatCommandP = "/_reject " *> (APIRejectContact <$> A.decimal <*> (" notify=" *> onOffP <|> pure False)), "/_service_request " *> (APISendServiceRequest <$> A.decimal <* A.space <*> strP <*> optional (" timeout=" *> (realToFrac <$> A.double)) <*> optional (" sign_key=" *> strP) <* A.space <*> jsonP), "/_service_response " *> (APISendServiceResponse <$> A.decimal <* A.space <*> strP <* A.space <*> jsonP), - "/_wallet create" $> APIWalletCreate, - "/_wallet import " *> (APIWalletImport <$> textP), + "/_wallet create new" $> APIWalletCreate Nothing, + "/_wallet create seed=" *> (APIWalletCreate . Just <$> textP), "/_wallet export " *> (APIWalletExportDerivedSecret <$> keyIndexP), "/_wallet export" $> APIWalletExportSeedMnemonic, "/_wallet delete" $> APIWalletDelete, diff --git a/tests/WalletTests.hs b/tests/WalletTests.hs index cf12eabcee..e84ed81eeb 100644 --- a/tests/WalletTests.hs +++ b/tests/WalletTests.hs @@ -75,18 +75,18 @@ testWalletCreate ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do alice <## "no wallet key" alice ##> "/_wallet export" alice <## "bad chat command: no wallet key on this device" - alice ##> "/_wallet create" + alice ##> "/_wallet create new" rows <- nameRows alice map fst rows `shouldBe` ["m/44'/60'/0'/0/1", "m/44'/60'/0'/0/2"] length (nub $ map snd rows) `shouldBe` 2 -- create is for the seed, and this device has one - alice ##> "/_wallet create" + alice ##> "/_wallet create new" alice <## "bad chat command: this device already has a wallet key" testWalletPersists :: HasCallStack => TestParams -> IO () testWalletPersists ps = do rows <- withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> "/_wallet create" + alice ##> "/_wallet create new" nameRows alice -- same database, new session: a name bought at that address must stay reachable withTestChat ps "alice" $ \alice -> do @@ -96,7 +96,7 @@ testWalletPersists ps = do testWalletSharedByProfiles :: HasCallStack => TestParams -> IO () testWalletSharedByProfiles ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice ##> ("/_wallet create seed=" <> B.unpack testPhrase) rows <- nameRows alice alice ##> "/create user alisa" showActiveUser alice "alisa" @@ -109,20 +109,20 @@ testWalletSharedByProfiles ps = withNewTestChat ps "alice" aliceProfile $ \alice testWalletImport :: HasCallStack => TestParams -> IO () testWalletImport ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice ##> ("/_wallet create seed=" <> B.unpack testPhrase) alice <## "m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0" alice <## "m/44'/60'/0'/0/2 0xb6716976A3ebe8D39aCEB04372f22Ff8e6802D7A" alice ##> "/_wallet export" alice <## B.unpack testPhrase - alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice ##> ("/_wallet create seed=" <> B.unpack testPhrase) alice <## "bad chat command: this device already has a wallet key" -- a mistyped phrase says nothing about which word was wrong - alice ##> ("/_wallet import " <> B.unpack (B.unwords $ replicate 12 "abandon")) + alice ##> ("/_wallet create seed=" <> B.unpack (B.unwords $ replicate 12 "abandon")) alice <## "bad chat command: bad recovery phrase" testWalletExportDerivedSecret :: HasCallStack => TestParams -> IO () testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice ##> ("/_wallet create seed=" <> B.unpack testPhrase) _ <- nameRows alice alice ##> "/_wallet export 1" alice <## "m/44'/60'/0'/0/1 0x6Fac4D18c912343BF86fa7049364Dd4E424Ab9C0 0x9a983cb3d832fbde5ab49d692b7a8bf5b5d232479c99333d0fc8e1d21f1b55b6" @@ -135,22 +135,22 @@ testWalletExportDerivedSecret ps = withNewTestChat ps "alice" aliceProfile $ \al testWalletDelete :: HasCallStack => TestParams -> IO () testWalletDelete ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice ##> ("/_wallet create seed=" <> B.unpack testPhrase) _ <- nameRows alice alice ##> "/_wallet delete" alice <## "no wallet key" - alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice ##> ("/_wallet create seed=" <> B.unpack testPhrase) _ <- nameRows alice pure () testWalletImportThenRestore :: HasCallStack => TestParams -> IO () testWalletImportThenRestore ps = withNewTestChat ps "alice" aliceProfile $ \alice -> do - alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice ##> ("/_wallet create seed=" <> B.unpack testPhrase) _ <- nameRows alice -- restoring the database replaces the seed with what the backup held, which is nothing forgetSeed alice alice ##> "/_wallet" alice <## "no wallet key" - alice ##> ("/_wallet import " <> B.unpack testPhrase) + alice ##> ("/_wallet create seed=" <> B.unpack testPhrase) rows <- nameRows alice map fst rows `shouldBe` ["m/44'/60'/0'/0/1", "m/44'/60'/0'/0/2"]