diff --git a/cmd/cli/admin.go b/cmd/cli/admin.go index 8d55c7f6a..ff956bb82 100644 --- a/cmd/cli/admin.go +++ b/cmd/cli/admin.go @@ -121,7 +121,7 @@ var ( Short: "delete the key associated with the address or nickname from the keystore", Args: cobra.MinimumNArgs(1), Run: func(cmd *cobra.Command, args []string) { - writeToConsole(client.KeystoreDelete(argGetAddrOrNickname(args[0]))) + writeToConsole(client.KeystoreDelete(argGetAddrOrNickname(args[0]), getPassword())) }, } diff --git a/cmd/rpc/README.md b/cmd/rpc/README.md index bb4cc67fc..b199a435a 100644 --- a/cmd/rpc/README.md +++ b/cmd/rpc/README.md @@ -3541,13 +3541,14 @@ $ curl -X POST http://localhost:50003/v1/admin/keystore-import-raw \ **Route:** `/v1/admin/keystore-delete` -**Description**: removes a key from the keystore using either the address or nickname +**Description**: removes a key from the keystore using either the address or nickname after validating the password **HTTP Method**: `POST` **Request**: - **nickname**: `string` - the nickname associated with the key - **address**: `string` - the address associated with the key +- **password**: `string` - **(required)** the plain-text password used to encrypt the key **Response**: `hex-string` - the 20 byte address of the newly imported key @@ -3556,7 +3557,8 @@ $ curl -X POST http://localhost:50003/v1/admin/keystore-import-raw \ $ curl -X POST http://localhost:50003/v1/admin/keystore-delete \ -H "Content-Type: application/json" \ -d '{ - "nickname":"my_key_2" + "nickname":"my_key_2", + "password":"my_password" }' > "b0b4a45ca70104ecc943a49e4553f0e7e1135b01" diff --git a/cmd/rpc/admin.go b/cmd/rpc/admin.go index 2c4ca5f4d..cd4e6f875 100644 --- a/cmd/rpc/admin.go +++ b/cmd/rpc/admin.go @@ -94,10 +94,12 @@ func (s *Server) KeystoreImportRaw(w http.ResponseWriter, r *http.Request, _ htt func (s *Server) KeystoreDelete(w http.ResponseWriter, r *http.Request, _ httprouter.Params) { // Call the keystore handler with a callback to perform the deletion s.keystoreHandler(w, r, func(k *crypto.Keystore, ptr *keystoreRequest) (any, error) { - k.DeleteKey(crypto.DeleteOpts{ + if err := k.DeleteKey(ptr.Password, crypto.DeleteOpts{ Address: ptr.Address, Nickname: ptr.Nickname, - }) + }); err != nil { + return nil, err + } // Update the keystore on disk and return the account address return ptr.Address, k.SaveToFile(s.config.DataDirPath) }) diff --git a/cmd/rpc/client.go b/cmd/rpc/client.go index 6b8a6de02..9fe579119 100644 --- a/cmd/rpc/client.go +++ b/cmd/rpc/client.go @@ -470,7 +470,7 @@ type AddrOrNickname struct { Nickname string } -func (c *Client) KeystoreDelete(addrOrNickname AddrOrNickname) (returned crypto.AddressI, err lib.ErrorI) { +func (c *Client) KeystoreDelete(addrOrNickname AddrOrNickname, password string) (returned crypto.AddressI, err lib.ErrorI) { returned = new(crypto.Address) if addrOrNickname.Address != "" { @@ -480,7 +480,8 @@ func (c *Client) KeystoreDelete(addrOrNickname AddrOrNickname) (returned crypto. return } err = c.keystoreRequest(KeystoreDeleteRouteName, keystoreRequest{ - addressRequest: addressRequest{bz}, + addressRequest: addressRequest{bz}, + passwordRequest: passwordRequest{password}, }, returned) return } @@ -488,6 +489,7 @@ func (c *Client) KeystoreDelete(addrOrNickname AddrOrNickname) (returned crypto. if addrOrNickname.Nickname != "" { err = c.keystoreRequest(KeystoreDeleteRouteName, keystoreRequest{ nicknameRequest: nicknameRequest{addrOrNickname.Nickname}, + passwordRequest: passwordRequest{password}, }, returned) return } diff --git a/cmd/rpc/web/wallet/public/plugin/canopy/chain.json b/cmd/rpc/web/wallet/public/plugin/canopy/chain.json index 5a9a90a67..23b0c662a 100644 --- a/cmd/rpc/web/wallet/public/plugin/canopy/chain.json +++ b/cmd/rpc/web/wallet/public/plugin/canopy/chain.json @@ -138,7 +138,8 @@ "method": "POST" }, "body": { - "nickname": "{{nickname}}" + "address": "{{address}}", + "password": "{{password}}" } }, "validator": { diff --git a/cmd/rpc/web/wallet/public/plugin/canopy/chain.json.template b/cmd/rpc/web/wallet/public/plugin/canopy/chain.json.template index 025208720..dacc383ba 100644 --- a/cmd/rpc/web/wallet/public/plugin/canopy/chain.json.template +++ b/cmd/rpc/web/wallet/public/plugin/canopy/chain.json.template @@ -57,7 +57,7 @@ }, "keystoreDelete": { "source": { "base": "admin", "path": "/v1/admin/keystore-delete", "method": "POST" }, - "body": { "nickname": "{{nickname}}" } + "body": { "address": "{{address}}", "password": "{{password}}" } }, "validator": { "source": { "base": "rpc", "path": "/v1/query/validator", "method": "POST" }, diff --git a/cmd/rpc/web/wallet/src/app/pages/KeyManagement.tsx b/cmd/rpc/web/wallet/src/app/pages/KeyManagement.tsx index 15d53da15..8d3e35ec6 100644 --- a/cmd/rpc/web/wallet/src/app/pages/KeyManagement.tsx +++ b/cmd/rpc/web/wallet/src/app/pages/KeyManagement.tsx @@ -144,8 +144,8 @@ export const KeyManagement = (): JSX.Element => { className="max-h-[90vh] max-w-[min(96vw,56rem)] gap-0 overflow-hidden border-[#272729] bg-[#171717] p-0" >
- {activeModal === 'import' ? : null} - {activeModal === 'create' ? : null} + {activeModal === 'import' ? setActiveModal(null)} /> : null} + {activeModal === 'create' ? setActiveModal(null)} /> : null}
diff --git a/cmd/rpc/web/wallet/src/app/providers/AccountsListProvider.tsx b/cmd/rpc/web/wallet/src/app/providers/AccountsListProvider.tsx index 0b625c4fa..b6941c297 100644 --- a/cmd/rpc/web/wallet/src/app/providers/AccountsListProvider.tsx +++ b/cmd/rpc/web/wallet/src/app/providers/AccountsListProvider.tsx @@ -30,7 +30,7 @@ type AccountsListContextValue = { isReady: boolean refetch: () => Promise createNewAccount: (nickname: string, password: string) => Promise - deleteAccount: (accountId: string, onDeleted?: (nextAccountId: string | null) => void) => Promise + deleteAccount: (accountId: string, password: string, onDeleted?: (nextAccountId: string | null) => void) => Promise } const AccountsListContext = createContext(undefined) @@ -76,6 +76,7 @@ export function AccountsListProvider({ children }: { children: React.ReactNode } const deleteAccount = useCallback(async ( accountId: string, + password: string, onDeleted?: (nextAccountId: string | null) => void ): Promise => { try { @@ -85,7 +86,8 @@ export function AccountsListProvider({ children }: { children: React.ReactNode } } await dsFetch('keystoreDelete', { - nickname: account.nickname + address: account.address, + password, }) // Notify caller about which account to switch to diff --git a/cmd/rpc/web/wallet/src/app/providers/AccountsProvider.tsx b/cmd/rpc/web/wallet/src/app/providers/AccountsProvider.tsx index 935d0e32d..b22949291 100644 --- a/cmd/rpc/web/wallet/src/app/providers/AccountsProvider.tsx +++ b/cmd/rpc/web/wallet/src/app/providers/AccountsProvider.tsx @@ -19,7 +19,7 @@ type AccountsContextValue = { switchAccount: (id: string | null) => void createNewAccount: (nickname: string, password: string) => Promise - deleteAccount: (accountId: string) => Promise + deleteAccount: (accountId: string, password: string) => Promise refetch: () => Promise } @@ -48,8 +48,8 @@ export function useAccounts(): AccountsContextValue { const selected = useSelectedAccount() // Wrap deleteAccount to integrate with switchAccount - const deleteAccount = useCallback(async (accountId: string): Promise => { - await list.deleteAccount(accountId, (nextAccountId) => { + const deleteAccount = useCallback(async (accountId: string, password: string): Promise => { + await list.deleteAccount(accountId, password, (nextAccountId: string | null) => { if (selected.selectedId === accountId && nextAccountId) { selected.switchAccount(nextAccountId) } diff --git a/cmd/rpc/web/wallet/src/components/governance/PollDetailsModal.tsx b/cmd/rpc/web/wallet/src/components/governance/PollDetailsModal.tsx index 7ff8de7f9..44b98cef1 100644 --- a/cmd/rpc/web/wallet/src/components/governance/PollDetailsModal.tsx +++ b/cmd/rpc/web/wallet/src/components/governance/PollDetailsModal.tsx @@ -40,6 +40,15 @@ export const PollDetailsModal: React.FC = ({ onClose, onVote, }) => { + React.useEffect(() => { + if (!isOpen) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [isOpen, onClose]); + if (!poll) return null; const normalizedHash = poll.proposalHash || poll.hash; diff --git a/cmd/rpc/web/wallet/src/components/governance/ProposalDetailsModal.tsx b/cmd/rpc/web/wallet/src/components/governance/ProposalDetailsModal.tsx index 8cec8beeb..fce4b27be 100644 --- a/cmd/rpc/web/wallet/src/components/governance/ProposalDetailsModal.tsx +++ b/cmd/rpc/web/wallet/src/components/governance/ProposalDetailsModal.tsx @@ -17,6 +17,16 @@ export const ProposalDetailsModal: React.FC = ({ onClose, }) => { const { symbol, factor } = useDenom(); + + React.useEffect(() => { + if (!isOpen) return; + const handler = (e: KeyboardEvent) => { + if (e.key === 'Escape') onClose(); + }; + window.addEventListener('keydown', handler); + return () => window.removeEventListener('keydown', handler); + }, [isOpen, onClose]); + if (!proposal) return null; const getCategoryColor = (category: string) => { diff --git a/cmd/rpc/web/wallet/src/components/key-management/CurrentWallet.tsx b/cmd/rpc/web/wallet/src/components/key-management/CurrentWallet.tsx index c637dbaed..0d6bce292 100644 --- a/cmd/rpc/web/wallet/src/components/key-management/CurrentWallet.tsx +++ b/cmd/rpc/web/wallet/src/components/key-management/CurrentWallet.tsx @@ -36,7 +36,8 @@ export const CurrentWallet = ({ embedded = false }: { embedded?: boolean }): JSX const [passwordError, setPasswordError] = useState(""); const [isFetchingKey, setIsFetchingKey] = useState(false); const [showDeleteModal, setShowDeleteModal] = useState(false); - const [deleteConfirmation, setDeleteConfirmation] = useState(""); + const [deletePassword, setDeletePassword] = useState(""); + const [deletePasswordError, setDeletePasswordError] = useState(""); const [isDeleting, setIsDeleting] = useState(false); const [isRenameOpen, setIsRenameOpen] = useState(false); const [renameNickname, setRenameNickname] = useState(""); @@ -217,7 +218,8 @@ export const CurrentWallet = ({ embedded = false }: { embedded?: boolean }): JSX return; } - setDeleteConfirmation(""); + setDeletePassword(""); + setDeletePasswordError(""); setShowDeleteModal(true); }; @@ -271,20 +273,20 @@ export const CurrentWallet = ({ embedded = false }: { embedded?: boolean }): JSX const handleConfirmDelete = async () => { if (!selectedAccount) return; - const nickname = selectedKeyEntry?.keyNickname || selectedAccount.nickname; - if (deleteConfirmation !== nickname) { - toast.error({ - title: "Confirmation Failed", - description: `Please type "${nickname}" to confirm deletion`, - }); + if (!deletePassword) { + setDeletePasswordError("Password is required."); return; } setIsDeleting(true); + setDeletePasswordError(""); try { + const nickname = selectedKeyEntry?.keyNickname || selectedAccount.nickname; + await dsFetch("keystoreDelete", { - nickname: nickname, + address: selectedKeyEntry?.keyAddress ?? selectedAccount.address, + password: deletePassword, }); await invalidateKeystore(); @@ -295,9 +297,8 @@ export const CurrentWallet = ({ embedded = false }: { embedded?: boolean }): JSX }); setShowDeleteModal(false); - setDeleteConfirmation(""); + setDeletePassword(""); - // Switch to another account const otherAccounts = accounts.filter((acc) => acc.id !== selectedAccount.id); if (otherAccounts.length > 0) { setTimeout(() => { @@ -307,6 +308,7 @@ export const CurrentWallet = ({ embedded = false }: { embedded?: boolean }): JSX switchAccount(null); } } catch (error) { + setDeletePasswordError("Unable to delete with that password."); toast.error({ title: "Delete Failed", description: error instanceof Error ? error.message : String(error), @@ -591,25 +593,29 @@ export const CurrentWallet = ({ embedded = false }: { embedded?: boolean }): JSX

- Type + Enter your wallet password to confirm deletion of {selectedKeyEntry?.keyNickname || selectedAccount?.nickname} - to confirm deletion: + :

setDeleteConfirmation(e.target.value)} - placeholder="Type wallet name to confirm" - className="w-full bg-[#0f0f0f] text-foreground border border-[#272729] rounded-lg px-3 py-2.5 mb-4 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#ff1845]/25" + type="password" + value={deletePassword} + onChange={(e) => setDeletePassword(e.target.value)} + placeholder="Password" + className="w-full bg-[#0f0f0f] text-foreground border border-[#272729] rounded-lg px-3 py-2.5 mb-2 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[#ff1845]/25" autoFocus /> + {deletePasswordError && ( +
{deletePasswordError}
+ )} -
+
diff --git a/cmd/rpc/web/wallet/src/components/key-management/ImportWallet.tsx b/cmd/rpc/web/wallet/src/components/key-management/ImportWallet.tsx index db8e44ef7..557a3013e 100644 --- a/cmd/rpc/web/wallet/src/components/key-management/ImportWallet.tsx +++ b/cmd/rpc/web/wallet/src/components/key-management/ImportWallet.tsx @@ -14,7 +14,7 @@ interface EncryptedKeyFile { keyNickname?: string; } -export const ImportWallet = ({ embedded = false }: { embedded?: boolean }): JSX.Element => { +export const ImportWallet = ({ embedded = false, onSuccess }: { embedded?: boolean; onSuccess?: () => void }): JSX.Element => { const toast = useToast(); const dsFetch = useDSFetcher(); const queryClient = useQueryClient(); @@ -101,6 +101,7 @@ export const ImportWallet = ({ embedded = false }: { embedded?: boolean }): JSX. }); setImportForm({ privateKey: '', password: '', confirmPassword: '', nickname: '' }); + onSuccess?.(); } catch (error) { toast.dismiss(loadingToast); toast.error({ @@ -195,6 +196,7 @@ export const ImportWallet = ({ embedded = false }: { embedded?: boolean }): JSX. setKeystoreFile(null); setKeystoreFileName(''); if (fileInputRef.current) fileInputRef.current.value = ''; + onSuccess?.(); } catch (error) { toast.dismiss(loadingToast); toast.error({ diff --git a/cmd/rpc/web/wallet/src/components/key-management/NewKey.tsx b/cmd/rpc/web/wallet/src/components/key-management/NewKey.tsx index 371c2c114..d9d540570 100644 --- a/cmd/rpc/web/wallet/src/components/key-management/NewKey.tsx +++ b/cmd/rpc/web/wallet/src/components/key-management/NewKey.tsx @@ -7,7 +7,7 @@ import { useToast } from '@/toast/ToastContext'; import { useDSFetcher } from '@/core/dsFetch'; import { useQueryClient } from '@tanstack/react-query'; -export const NewKey = ({ embedded = false }: { embedded?: boolean }): JSX.Element => { +export const NewKey = ({ embedded = false, onSuccess }: { embedded?: boolean; onSuccess?: () => void }): JSX.Element => { const { switchAccount } = useAccounts(); const toast = useToast(); const dsFetch = useDSFetcher(); @@ -59,6 +59,7 @@ export const NewKey = ({ embedded = false }: { embedded?: boolean }): JSX.Elemen }); setNewKeyForm({ password: '', walletName: '' }); + onSuccess?.(); const newAddress = typeof response === 'string' ? response : (response as any)?.address; if (newAddress) { diff --git a/lib/crypto/keystore.go b/lib/crypto/keystore.go index b8c8f397a..211f1973f 100644 --- a/lib/crypto/keystore.go +++ b/lib/crypto/keystore.go @@ -181,8 +181,8 @@ type DeleteOpts struct { Nickname string } -// DeleteKey() removes a private key from the store given an address and/or nickname -func (ks *Keystore) DeleteKey(opts DeleteOpts) { +// DeleteKey() removes a private key from the store given an address and/or nickname after validating the password +func (ks *Keystore) DeleteKey(password string, opts DeleteOpts) error { var stringAddress string if opts.Address != nil { stringAddress = hex.EncodeToString(opts.Address) @@ -192,10 +192,21 @@ func (ks *Keystore) DeleteKey(opts DeleteOpts) { } pKey := ks.AddressMap[stringAddress] + if pKey == nil { + return fmt.Errorf("key not found") + } + if password == "" { + return fmt.Errorf("invalid password") + } + if _, err := DecryptPrivateKey(pKey, []byte(password)); err != nil { + return err + } + if pKey.KeyNickname != "" { delete(ks.NicknameMap, pKey.KeyNickname) } delete(ks.AddressMap, pKey.KeyAddress) + return nil } // SaveToFile() persists the keystore to a filepath diff --git a/lib/crypto/keystore_test.go b/lib/crypto/keystore_test.go index e8cc9b09f..7d473766e 100644 --- a/lib/crypto/keystore_test.go +++ b/lib/crypto/keystore_test.go @@ -80,9 +80,9 @@ func TestKeystoreDeleteKeyWithOpts(t *testing.T) { // validate got address vs expected require.Equal(t, hex.EncodeToString(address), gotAddress) // delete the key - ks.DeleteKey(DeleteOpts{ + require.NoError(t, ks.DeleteKey(password, DeleteOpts{ Address: address, - }) + })) // check the key was imported with address _, err = ks.GetKey(address, password) require.ErrorContains(t, err, "key not found") @@ -100,9 +100,9 @@ func TestKeystoreDeleteKeyWithOpts(t *testing.T) { // validate got address vs expected require.Equal(t, hex.EncodeToString(address), gotAddress) // delete the key - ks.DeleteKey(DeleteOpts{ + require.NoError(t, ks.DeleteKey(password, DeleteOpts{ Nickname: "pablito", - }) + })) // check the key was imported with address _, err = ks.GetKey(address, password) require.ErrorContains(t, err, "key not found")