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"]