From 4a9182c261d173831a2014c1621159674cc047ae Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Thu, 24 Sep 2026 13:56:35 +0000 Subject: [PATCH 01/11] Add login seed recovery flow with rate-limited backend Implement passphrase reset via BIP39 seed or hex private key proof, unauthenticated recover API endpoint, dual-bucket rate limiting, and /login/recover-seed UI with success banner on return to login. Co-authored-by: Guillaume De Saint Martin --- octobot/community/authentication.py | 14 + octobot/community/wallet_backend/__init__.py | 4 + .../wallet_backend/community_wallet.py | 71 ++++ octobot/community/wallet_backend/errors.py | 8 + .../recover_passphrase_rate_limit.py | 102 ++++++ .../node_api_interface/api/routes/setup.py | 112 +++++- .../tests/test_routes_recover_wallet.py | 98 ++++++ .../passphrase-recovery-validation.test.ts | 35 ++ .../src/lib/login-passphrase-recovery-hint.ts | 15 + .../src/lib/passphrase-recovery-api.ts | 44 +++ .../src/lib/passphrase-recovery-validation.ts | 26 ++ .../node_web_interface/src/routeTree.gen.ts | 39 ++- .../src/routes/login.recover-seed.tsx | 327 ++++++++++++++++++ .../node_web_interface/src/routes/login.tsx | 39 ++- .../test_recover_passphrase_rate_limit.py | 44 +++ .../community/test_wallet_backend.py | 79 ++++- 16 files changed, 1049 insertions(+), 8 deletions(-) create mode 100644 octobot/community/wallet_backend/recover_passphrase_rate_limit.py create mode 100644 packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_recover_wallet.py create mode 100644 packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/passphrase-recovery-validation.test.ts create mode 100644 packages/tentacles/Services/Interfaces/node_web_interface/src/lib/login-passphrase-recovery-hint.ts create mode 100644 packages/tentacles/Services/Interfaces/node_web_interface/src/lib/passphrase-recovery-api.ts create mode 100644 packages/tentacles/Services/Interfaces/node_web_interface/src/lib/passphrase-recovery-validation.ts create mode 100644 packages/tentacles/Services/Interfaces/node_web_interface/src/routes/login.recover-seed.tsx create mode 100644 tests/unit_tests/community/test_recover_passphrase_rate_limit.py diff --git a/octobot/community/authentication.py b/octobot/community/authentication.py index 22b11ddbfc..7684eb8322 100644 --- a/octobot/community/authentication.py +++ b/octobot/community/authentication.py @@ -692,6 +692,20 @@ def authenticate_wallet(self, address: str, passphrase: str) -> dict: def verify_wallet_passphrase(self, address: str, passphrase: str) -> bool: return self._wallet_backend.verify_wallet_passphrase(address, passphrase) + def recover_passphrase_from_ownership_proof( + self, + address: str, + new_passphrase: str, + seed: typing.Optional[str] = None, + private_key: typing.Optional[str] = None, + ) -> None: + return self._wallet_backend.recover_passphrase_from_ownership_proof( + address, + new_passphrase, + seed=seed, + private_key=private_key, + ) + def decrypt_wallet_by_address(self, address: str, passphrase: str): return self._wallet_backend.decrypt_wallet_by_address(address, passphrase) diff --git a/octobot/community/wallet_backend/__init__.py b/octobot/community/wallet_backend/__init__.py index 4216a560d9..8b6f73990e 100644 --- a/octobot/community/wallet_backend/__init__.py +++ b/octobot/community/wallet_backend/__init__.py @@ -31,6 +31,8 @@ CannotRemoveAdminWalletError, InvalidPrivateKeyError, PassphraseTooShortError, + WalletProofMismatchError, + WalletStorageReadOnlyError, ) from octobot.community.wallet_backend import wallet_storage from octobot.community.wallet_backend.wallet_storage import ( @@ -54,6 +56,8 @@ "CannotRemoveAdminWalletError", "InvalidPrivateKeyError", "PassphraseTooShortError", + "WalletProofMismatchError", + "WalletStorageReadOnlyError", "WalletStorage", "ConfigJsonWalletStorage", "DedicatedFileWalletStorage", diff --git a/octobot/community/wallet_backend/community_wallet.py b/octobot/community/wallet_backend/community_wallet.py index effa755507..fe11f578be 100644 --- a/octobot/community/wallet_backend/community_wallet.py +++ b/octobot/community/wallet_backend/community_wallet.py @@ -34,6 +34,8 @@ WalletAlreadyExistsError, WalletError, WalletNotFoundError, + WalletProofMismatchError, + WalletStorageReadOnlyError, ) from octobot.community.wallet_backend.wallet_storage import ( WalletStorage, @@ -323,3 +325,72 @@ def is_admin_wallet(self, address: str) -> bool: def get_wallet_name(self, address: str) -> typing.Optional[str]: entry = self._find_wallet_entry(address) return entry.name if entry else None + + def recover_passphrase_from_ownership_proof( + self, + address: str, + new_passphrase: str, + seed: typing.Optional[str] = None, + private_key: typing.Optional[str] = None, + ) -> None: + """Replace passphrase_hash after proving ownership via BIP39 seed or hex private key.""" + if len(new_passphrase) < 8: + raise PassphraseTooShortError("Passphrase must be at least 8 characters") + + seed_value = seed.strip() if seed else "" + key_value = private_key.strip() if private_key else "" + if bool(seed_value) == bool(key_value): + raise InvalidPrivateKeyError( + "Provide exactly one of seed phrase or private key" + ) + + normalized_target = address.lower() + if self._find_wallet_entry(address) is None: + wallet_error = WalletNotFoundError(f"Wallet {address} not found") + _record_wallet_operation_failure(operation="recover_passphrase", error=wallet_error) + raise wallet_error + + try: + if seed_value: + derived = sync_chain.wallet_from_mnemonic(seed_value) + else: + derived_address = sync_chain.address_from_evm_key(key_value) + derived = sync_chain.Wallet(private_key=key_value, address=derived_address) + except Exception as err: + raise InvalidPrivateKeyError("Invalid seed phrase or private key") from err + + if derived.address.lower() != normalized_target: + raise WalletProofMismatchError( + "Seed phrase or private key does not match this wallet" + ) + + new_hash = _hash_passphrase(new_passphrase) + with self._wallet_lock: + node_wallets = self._get_node_wallets_list() + updated: list[WalletEntry] = [] + found = False + for entry in node_wallets: + if entry.address == normalized_target: + found = True + updated.append( + WalletEntry( + address=entry.address, + name=entry.name, + is_admin=entry.is_admin, + private_key=entry.private_key, + passphrase_hash=new_hash, + seed=entry.seed, + ) + ) + else: + updated.append(entry) + if not found: + wallet_error = WalletNotFoundError(f"Wallet {address} not found") + _record_wallet_operation_failure(operation="recover_passphrase", error=wallet_error) + raise wallet_error + try: + self._save_node_wallets_list(updated) + except NotImplementedError as err: + raise WalletStorageReadOnlyError( + "Wallet storage is read-only; passphrase cannot be changed on this node" + ) from err diff --git a/octobot/community/wallet_backend/errors.py b/octobot/community/wallet_backend/errors.py index c523c4fbde..a9f0371348 100644 --- a/octobot/community/wallet_backend/errors.py +++ b/octobot/community/wallet_backend/errors.py @@ -49,3 +49,11 @@ class InvalidPrivateKeyError(WalletError): class PassphraseTooShortError(WalletError): pass + + +class WalletProofMismatchError(WalletError): + pass + + +class WalletStorageReadOnlyError(WalletError): + pass diff --git a/octobot/community/wallet_backend/recover_passphrase_rate_limit.py b/octobot/community/wallet_backend/recover_passphrase_rate_limit.py new file mode 100644 index 0000000000..6bf7017204 --- /dev/null +++ b/octobot/community/wallet_backend/recover_passphrase_rate_limit.py @@ -0,0 +1,102 @@ +# This file is part of OctoBot (https://github.com/Drakkar-Software/OctoBot) +# Copyright (c) 2025 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot. If not, see . + +import dataclasses +import threading +import time + +_IP_MAX_FAILURES = 5 +_IP_WINDOW_SECONDS = 15 * 60 +_ADDRESS_MAX_FAILURES = 10 +_ADDRESS_WINDOW_SECONDS = 60 * 60 + + +@dataclasses.dataclass +class _FailureBucket: + failure_count: int = 0 + window_start: float = 0.0 + + +class RecoverPassphraseRateLimiter: + """In-process dual-bucket rate limiter for passphrase recovery attempts.""" + + def __init__(self) -> None: + self._lock = threading.Lock() + self._ip_buckets: dict[str, _FailureBucket] = {} + self._address_buckets: dict[str, _FailureBucket] = {} + + def reset_all(self) -> None: + with self._lock: + self._ip_buckets.clear() + self._address_buckets.clear() + + def _normalize_address(self, address: str) -> str: + return address.lower() + + def _is_limited( + self, + buckets: dict[str, _FailureBucket], + key: str, + max_failures: int, + window_seconds: float, + ) -> bool: + now = time.monotonic() + bucket = buckets.get(key) + if bucket is None or now - bucket.window_start >= window_seconds: + buckets[key] = _FailureBucket(failure_count=0, window_start=now) + return False + return bucket.failure_count >= max_failures + + def is_rate_limited(self, client_ip: str, address: str) -> bool: + normalized_address = self._normalize_address(address) + with self._lock: + if self._is_limited( + self._ip_buckets, client_ip, _IP_MAX_FAILURES, _IP_WINDOW_SECONDS + ): + return True + return self._is_limited( + self._address_buckets, + normalized_address, + _ADDRESS_MAX_FAILURES, + _ADDRESS_WINDOW_SECONDS, + ) + + def record_failure(self, client_ip: str, address: str) -> None: + normalized_address = self._normalize_address(address) + now = time.monotonic() + with self._lock: + for buckets, key, window_seconds in ( + (self._ip_buckets, client_ip, _IP_WINDOW_SECONDS), + (self._address_buckets, normalized_address, _ADDRESS_WINDOW_SECONDS), + ): + bucket = buckets.get(key) + if bucket is None or now - bucket.window_start >= window_seconds: + bucket = _FailureBucket(failure_count=0, window_start=now) + buckets[key] = bucket + bucket.failure_count += 1 + + def record_success(self, client_ip: str, address: str) -> None: + normalized_address = self._normalize_address(address) + with self._lock: + self._ip_buckets.pop(client_ip, None) + self._address_buckets.pop(normalized_address, None) + + +_recover_passphrase_rate_limiter = RecoverPassphraseRateLimiter() + + +def get_recover_passphrase_rate_limiter() -> RecoverPassphraseRateLimiter: + return _recover_passphrase_rate_limiter diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/setup.py b/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/setup.py index 85e5b858b9..9c08d905e1 100644 --- a/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/setup.py +++ b/packages/tentacles/Services/Interfaces/node_api_interface/api/routes/setup.py @@ -17,7 +17,7 @@ import typing import pydantic -from fastapi import APIRouter, Depends, HTTPException, status +from fastapi import APIRouter, Depends, HTTPException, Request, status from fastapi.security import HTTPBasicCredentials import octobot_node.config as node_config @@ -34,6 +34,10 @@ from tentacles.Services.Interfaces.node_api_interface.api.deps import CurrentUser, security_basic from tentacles.Services.Interfaces.node_api_interface.core import network +from octobot.community.wallet_backend.recover_passphrase_rate_limit import ( + get_recover_passphrase_rate_limiter, +) + router = APIRouter(tags=["setup"]) @@ -66,6 +70,23 @@ class VPNNetworkAddress(pydantic.BaseModel): vpn_network_ip: typing.Optional[str] = None +class RecoverWalletFromSeedBody(pydantic.BaseModel): + address: str + new_passphrase: str + seed: typing.Optional[str] = None + private_key: typing.Optional[str] = None + + +class RecoverWalletFromSeedResult(pydantic.BaseModel): + success: bool = True + + +def _client_ip(request: Request) -> str: + if request.client is not None: + return request.client.host + return "unknown" + + @router.get("/setup/status", response_model=SetupStatus) def get_setup_status() -> SetupStatus: auth = community_auth.CommunityAuthentication.instance() @@ -181,3 +202,92 @@ def export_wallet( detail="Invalid passphrase", ) return WalletExport(address=entry.address, private_key=entry.private_key, seed=entry.seed or None) + + +@router.post("/setup/wallet/recover-from-seed", response_model=RecoverWalletFromSeedResult) +def recover_wallet_from_seed( + body: RecoverWalletFromSeedBody, + request: Request, +) -> RecoverWalletFromSeedResult: + auth = community_auth.CommunityAuthentication.instance() + if auth is None: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail="Node not configured", + ) + client_ip = _client_ip(request) + rate_limiter = get_recover_passphrase_rate_limiter() + if rate_limiter.is_rate_limited(client_ip, body.address): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many recovery attempts. Try again later.", + ) + try: + auth.recover_passphrase_from_ownership_proof( + address=body.address, + new_passphrase=body.new_passphrase, + seed=body.seed, + private_key=body.private_key, + ) + except wallet_backend.WalletNotFoundError: + rate_limiter.record_failure(client_ip, body.address) + if rate_limiter.is_rate_limited(client_ip, body.address): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many recovery attempts. Try again later.", + ) + raise HTTPException( + status_code=status.HTTP_404_NOT_FOUND, + detail="Wallet not found", + ) + except wallet_backend.WalletProofMismatchError as err: + rate_limiter.record_failure(client_ip, body.address) + if rate_limiter.is_rate_limited(client_ip, body.address): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many recovery attempts. Try again later.", + ) + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail=str(err), + ) + except wallet_backend.InvalidPrivateKeyError as err: + rate_limiter.record_failure(client_ip, body.address) + if rate_limiter.is_rate_limited(client_ip, body.address): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many recovery attempts. Try again later.", + ) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(err), + ) + except wallet_backend.PassphraseTooShortError as err: + rate_limiter.record_failure(client_ip, body.address) + if rate_limiter.is_rate_limited(client_ip, body.address): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many recovery attempts. Try again later.", + ) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(err), + ) + except wallet_backend.WalletStorageReadOnlyError as err: + raise HTTPException( + status_code=status.HTTP_503_SERVICE_UNAVAILABLE, + detail=str(err), + ) + except wallet_backend.WalletError as err: + rate_limiter.record_failure(client_ip, body.address) + if rate_limiter.is_rate_limited(client_ip, body.address): + raise HTTPException( + status_code=status.HTTP_429_TOO_MANY_REQUESTS, + detail="Too many recovery attempts. Try again later.", + ) + raise HTTPException( + status_code=status.HTTP_422_UNPROCESSABLE_ENTITY, + detail=str(err), + ) + rate_limiter.record_success(client_ip, body.address) + return RecoverWalletFromSeedResult() diff --git a/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_recover_wallet.py b/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_recover_wallet.py new file mode 100644 index 0000000000..86f6e618ad --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_api_interface/tests/test_routes_recover_wallet.py @@ -0,0 +1,98 @@ +# This file is part of OctoBot Node (https://github.com/Drakkar-Software/OctoBot-Node) +# Copyright (c) 2025 Drakkar-Software, All rights reserved. +# +# OctoBot is free software; you can redistribute it and/or +# modify it under the terms of the GNU General Public License +# as published by the Free Software Foundation; either +# version 3.0 of the License, or (at your option) any later version. +# +# OctoBot is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the GNU +# General Public License for more details. +# +# You should have received a copy of the GNU General Public +# License along with OctoBot. If not, see . + +from unittest import mock + +import octobot.community.wallet_backend as wallet_backend + +from octobot.community.wallet_backend.recover_passphrase_rate_limit import ( + get_recover_passphrase_rate_limiter, +) + +from .conftest import ADMIN_ADDRESS + +_TEST_MNEMONIC = "test test test test test test test test test test test junk" +_RECOVER_URL = "/api/v1/setup/wallet/recover-from-seed" + + +def _recover_body(**overrides): + body = { + "address": ADMIN_ADDRESS, + "new_passphrase": "new-passphrase99", + "seed": _TEST_MNEMONIC, + } + body.update(overrides) + return body + + +def test_recover_wallet_success(client): + auth = mock.MagicMock() + with mock.patch( + "octobot.community.authentication.CommunityAuthentication.instance", + return_value=auth, + ): + get_recover_passphrase_rate_limiter().reset_all() + resp = client.post(_RECOVER_URL, json=_recover_body()) + assert resp.status_code == 200 + assert resp.json() == {"success": True} + auth.recover_passphrase_from_ownership_proof.assert_called_once() + + +def test_recover_wallet_mismatch_returns_401(client): + auth = mock.MagicMock() + auth.recover_passphrase_from_ownership_proof.side_effect = ( + wallet_backend.WalletProofMismatchError("mismatch") + ) + with mock.patch( + "octobot.community.authentication.CommunityAuthentication.instance", + return_value=auth, + ): + get_recover_passphrase_rate_limiter().reset_all() + resp = client.post(_RECOVER_URL, json=_recover_body()) + assert resp.status_code == 401 + + +def test_recover_wallet_read_only_returns_503(client): + auth = mock.MagicMock() + auth.recover_passphrase_from_ownership_proof.side_effect = ( + wallet_backend.WalletStorageReadOnlyError("read-only") + ) + with mock.patch( + "octobot.community.authentication.CommunityAuthentication.instance", + return_value=auth, + ): + get_recover_passphrase_rate_limiter().reset_all() + resp = client.post(_RECOVER_URL, json=_recover_body()) + assert resp.status_code == 503 + assert "read-only" in resp.json()["detail"].lower() + + +def test_recover_wallet_rate_limited_after_ip_failures(client): + auth = mock.MagicMock() + auth.recover_passphrase_from_ownership_proof.side_effect = ( + wallet_backend.WalletProofMismatchError("mismatch") + ) + with mock.patch( + "octobot.community.authentication.CommunityAuthentication.instance", + return_value=auth, + ): + limiter = get_recover_passphrase_rate_limiter() + limiter.reset_all() + for _ in range(5): + resp = client.post(_RECOVER_URL, json=_recover_body()) + assert resp.status_code == 401 + resp = client.post(_RECOVER_URL, json=_recover_body()) + assert resp.status_code == 429 diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/passphrase-recovery-validation.test.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/passphrase-recovery-validation.test.ts new file mode 100644 index 0000000000..ec41b92056 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/__tests__/passphrase-recovery-validation.test.ts @@ -0,0 +1,35 @@ +import { describe, expect, it } from "vitest" + +import { + countSeedWords, + isPassphraseLongEnough, + isValidEvmPrivateKeyHex, + isValidSeedPhrase, + passphrasesMatch, +} from "@/lib/passphrase-recovery-validation" + +describe("passphrase-recovery-validation", () => { + it("counts seed words", () => { + expect(countSeedWords("one two three")).toBe(3) + }) + + it("validates seed phrase length", () => { + const twelve = "a b c d e f g h i j k l" + expect(isValidSeedPhrase(twelve)).toBe(true) + expect(isValidSeedPhrase("too short")).toBe(false) + }) + + it("validates hex private key", () => { + const key = "0".repeat(64) + expect(isValidEvmPrivateKeyHex(key)).toBe(true) + expect(isValidEvmPrivateKeyHex("0x" + key)).toBe(true) + expect(isValidEvmPrivateKeyHex("abc")).toBe(false) + }) + + it("validates passphrase length and match", () => { + expect(isPassphraseLongEnough("12345678")).toBe(true) + expect(isPassphraseLongEnough("short")).toBe(false) + expect(passphrasesMatch("a", "a")).toBe(true) + expect(passphrasesMatch("a", "b")).toBe(false) + }) +}) diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/login-passphrase-recovery-hint.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/login-passphrase-recovery-hint.ts new file mode 100644 index 0000000000..e130a5827c --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/login-passphrase-recovery-hint.ts @@ -0,0 +1,15 @@ +export const LOGIN_PASSPHRASE_RECOVERY_SUCCESS_STORAGE_KEY = + "octobot_login_passphrase_recovery_success" + +export function markLoginPassphraseRecoverySuccess(): void { + sessionStorage.setItem(LOGIN_PASSPHRASE_RECOVERY_SUCCESS_STORAGE_KEY, "1") +} + +export function consumeLoginPassphraseRecoverySuccessHint(): boolean { + const value = sessionStorage.getItem(LOGIN_PASSPHRASE_RECOVERY_SUCCESS_STORAGE_KEY) + if (!value) { + return false + } + sessionStorage.removeItem(LOGIN_PASSPHRASE_RECOVERY_SUCCESS_STORAGE_KEY) + return true +} diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/passphrase-recovery-api.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/passphrase-recovery-api.ts new file mode 100644 index 0000000000..c0699729f8 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/passphrase-recovery-api.ts @@ -0,0 +1,44 @@ +import { ApiError } from "@/client" + +export type RecoverWalletFromSeedRequest = { + address: string + new_passphrase: string + seed?: string | null + private_key?: string | null +} + +export async function recoverWalletFromSeed( + body: RecoverWalletFromSeedRequest, +): Promise { + const response = await fetch("/api/v1/setup/wallet/recover-from-seed", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(body), + }) + if (!response.ok) { + let message = response.statusText + try { + const payload = (await response.json()) as { detail?: string } + if (payload.detail) { + message = payload.detail + } + } catch { + // ignore JSON parse errors + } + throw new ApiError( + { + url: "/api/v1/setup/wallet/recover-from-seed", + method: "POST", + headers: {}, + }, + { + url: response.url, + ok: response.ok, + status: response.status, + statusText: response.statusText, + body: message, + }, + message, + ) + } +} diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/passphrase-recovery-validation.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/passphrase-recovery-validation.ts new file mode 100644 index 0000000000..d772d4a4b9 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/lib/passphrase-recovery-validation.ts @@ -0,0 +1,26 @@ +const EVM_PRIVATE_KEY_PATTERN = /^(0x)?[0-9a-fA-F]{64}$/ + +export type PassphraseRecoveryProofMode = "seed" | "hex" + +export function countSeedWords(seed: string): number { + return seed.trim().split(/\s+/).filter(Boolean).length +} + +export function isValidSeedPhrase(seed: string): boolean { + return countSeedWords(seed) >= 12 +} + +export function isValidEvmPrivateKeyHex(privateKey: string): boolean { + return EVM_PRIVATE_KEY_PATTERN.test(privateKey.trim()) +} + +export function isPassphraseLongEnough(passphrase: string): boolean { + return passphrase.length >= 8 +} + +export function passphrasesMatch( + passphrase: string, + confirmPassphrase: string, +): boolean { + return passphrase === confirmPassphrase +} diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/routeTree.gen.ts b/packages/tentacles/Services/Interfaces/node_web_interface/src/routeTree.gen.ts index 9644542172..8d0bea1257 100644 --- a/packages/tentacles/Services/Interfaces/node_web_interface/src/routeTree.gen.ts +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/routeTree.gen.ts @@ -18,6 +18,7 @@ import { Route as LayoutDslKeywordsRouteImport } from './routes/_layout/dsl-keyw import { Route as LayoutOctobotsRouteImport } from './routes/_layout/octobots' import { Route as LayoutSettingsRouteImport } from './routes/_layout/settings' import { Route as LayoutSupportRouteImport } from './routes/_layout/support' +import { Route as LoginRecoverSeedRouteImport } from './routes/login.recover-seed' import { Route as SetupIndexRouteImport } from './routes/setup/index' import { Route as SetupConnectRouteImport } from './routes/setup/connect' import { Route as SetupFirstBotRouteImport } from './routes/setup/first-bot' @@ -77,6 +78,11 @@ const LayoutSupportRoute = LayoutSupportRouteImport.update({ path: '/support', getParentRoute: () => LayoutRoute, } as any) +const LoginRecoverSeedRoute = LoginRecoverSeedRouteImport.update({ + id: '/recover-seed', + path: '/recover-seed', + getParentRoute: () => LoginRoute, +} as any) const SetupIndexRoute = SetupIndexRouteImport.update({ id: '/', path: '/', @@ -153,13 +159,14 @@ const LayoutOctobotsNewPresetsRoute = export interface FileRoutesByFullPath { '/': typeof LayoutIndexRoute - '/login': typeof LoginRoute + '/login': typeof LoginRouteWithChildren '/setup': typeof SetupRouteWithChildren '/debug': typeof LayoutDebugRoute '/dsl-keywords': typeof LayoutDslKeywordsRoute '/octobots': typeof LayoutOctobotsRouteWithChildren '/settings': typeof LayoutSettingsRouteWithChildren '/support': typeof LayoutSupportRoute + '/login/recover-seed': typeof LoginRecoverSeedRoute '/setup/connect': typeof SetupConnectRoute '/setup/first-bot': typeof SetupFirstBotRoute '/setup/mobile-app': typeof SetupMobileAppRoute @@ -176,10 +183,11 @@ export interface FileRoutesByFullPath { '/octobots/new/presets': typeof LayoutOctobotsNewPresetsRoute } export interface FileRoutesByTo { - '/login': typeof LoginRoute + '/login': typeof LoginRouteWithChildren '/debug': typeof LayoutDebugRoute '/dsl-keywords': typeof LayoutDslKeywordsRoute '/support': typeof LayoutSupportRoute + '/login/recover-seed': typeof LoginRecoverSeedRoute '/setup/connect': typeof SetupConnectRoute '/setup/first-bot': typeof SetupFirstBotRoute '/setup/mobile-app': typeof SetupMobileAppRoute @@ -199,13 +207,14 @@ export interface FileRoutesByTo { export interface FileRoutesById { __root__: typeof rootRouteImport '/_layout': typeof LayoutRouteWithChildren - '/login': typeof LoginRoute + '/login': typeof LoginRouteWithChildren '/setup': typeof SetupRouteWithChildren '/_layout/debug': typeof LayoutDebugRoute '/_layout/dsl-keywords': typeof LayoutDslKeywordsRoute '/_layout/octobots': typeof LayoutOctobotsRouteWithChildren '/_layout/settings': typeof LayoutSettingsRouteWithChildren '/_layout/support': typeof LayoutSupportRoute + '/login/recover-seed': typeof LoginRecoverSeedRoute '/setup/connect': typeof SetupConnectRoute '/setup/first-bot': typeof SetupFirstBotRoute '/setup/mobile-app': typeof SetupMobileAppRoute @@ -233,6 +242,7 @@ export interface FileRouteTypes { | '/octobots' | '/settings' | '/support' + | '/login/recover-seed' | '/setup/connect' | '/setup/first-bot' | '/setup/mobile-app' @@ -253,6 +263,7 @@ export interface FileRouteTypes { | '/debug' | '/dsl-keywords' | '/support' + | '/login/recover-seed' | '/setup/connect' | '/setup/first-bot' | '/setup/mobile-app' @@ -278,6 +289,7 @@ export interface FileRouteTypes { | '/_layout/octobots' | '/_layout/settings' | '/_layout/support' + | '/login/recover-seed' | '/setup/connect' | '/setup/first-bot' | '/setup/mobile-app' @@ -297,7 +309,7 @@ export interface FileRouteTypes { } export interface RootRouteChildren { LayoutRoute: typeof LayoutRouteWithChildren - LoginRoute: typeof LoginRoute + LoginRoute: typeof LoginRouteWithChildren SetupRoute: typeof SetupRouteWithChildren } @@ -366,6 +378,13 @@ declare module '@tanstack/react-router' { preLoaderRoute: typeof LayoutSupportRouteImport parentRoute: typeof LayoutRoute } + '/login/recover-seed': { + id: '/login/recover-seed' + path: '/recover-seed' + fullPath: '/login/recover-seed' + preLoaderRoute: typeof LoginRecoverSeedRouteImport + parentRoute: typeof LoginRoute + } '/setup/': { id: '/setup/' path: '/' @@ -535,6 +554,16 @@ const LayoutRouteChildren: LayoutRouteChildren = { const LayoutRouteWithChildren = LayoutRoute._addFileChildren(LayoutRouteChildren) +interface LoginRouteChildren { + LoginRecoverSeedRoute: typeof LoginRecoverSeedRoute +} + +const LoginRouteChildren: LoginRouteChildren = { + LoginRecoverSeedRoute: LoginRecoverSeedRoute, +} + +const LoginRouteWithChildren = LoginRoute._addFileChildren(LoginRouteChildren) + interface SetupRouteChildren { SetupConnectRoute: typeof SetupConnectRoute SetupFirstBotRoute: typeof SetupFirstBotRoute @@ -555,7 +584,7 @@ const SetupRouteWithChildren = SetupRoute._addFileChildren(SetupRouteChildren) const rootRouteChildren: RootRouteChildren = { LayoutRoute: LayoutRouteWithChildren, - LoginRoute: LoginRoute, + LoginRoute: LoginRouteWithChildren, SetupRoute: SetupRouteWithChildren, } export const routeTree = rootRouteImport diff --git a/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/login.recover-seed.tsx b/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/login.recover-seed.tsx new file mode 100644 index 0000000000..d9c53242f1 --- /dev/null +++ b/packages/tentacles/Services/Interfaces/node_web_interface/src/routes/login.recover-seed.tsx @@ -0,0 +1,327 @@ +import { zodResolver } from "@hookform/resolvers/zod" +import { createFileRoute, Link, redirect, useNavigate } from "@tanstack/react-router" +import { TriangleAlert } from "lucide-react" +import { useState } from "react" +import { useForm } from "react-hook-form" +import { z } from "zod" + +import { type ApiError } from "@/client" +import { AuthLayout } from "@/components/Common/AuthLayout" +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form" +import { LoadingButton } from "@/components/ui/loading-button" +import { PasswordInput } from "@/components/ui/password-input" +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs" +import { isLoggedIn } from "@/hooks/useAuth" +import { markLoginPassphraseRecoverySuccess } from "@/lib/login-passphrase-recovery-hint" +import { recoverWalletFromSeed } from "@/lib/passphrase-recovery-api" +import type { PassphraseRecoveryProofMode } from "@/lib/passphrase-recovery-validation" +import { truncateAddress } from "@/lib/wallet-utils" +import { extractErrorMessage } from "@/utils" + +const searchSchema = z.object({ + address: z.string().min(1), +}) + +const baseSchema = z.object({ + newPassphrase: z + .string() + .min(8, { message: "Passphrase must be at least 8 characters" }), + confirmPassphrase: z.string(), + seed: z.string().optional(), + privateKey: z.string().optional(), +}) + +const formSchema = baseSchema + .refine((data) => data.newPassphrase === data.confirmPassphrase, { + message: "Passphrases do not match", + path: ["confirmPassphrase"], + }) + .superRefine((data, ctx) => { + const mode: PassphraseRecoveryProofMode | null = data.privateKey?.trim() + ? "hex" + : data.seed?.trim() + ? "seed" + : null + if (mode === "hex") { + if (!/^(0x)?[0-9a-fA-F]{64}$/.test(data.privateKey?.trim() ?? "")) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Must be a valid 64-hex-char EVM private key", + path: ["privateKey"], + }) + } + } else if (mode === "seed") { + const words = (data.seed?.trim() ?? "").split(/\s+/).filter(Boolean) + if (words.length < 12) { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter your full seed phrase (at least 12 words)", + path: ["seed"], + }) + } + } else { + ctx.addIssue({ + code: z.ZodIssueCode.custom, + message: "Enter your seed phrase or private key", + path: ["seed"], + }) + } + }) + +type FormData = z.infer + +export const Route = createFileRoute("/login/recover-seed")({ + validateSearch: searchSchema, + beforeLoad: ({ search }) => { + if (isLoggedIn()) { + throw redirect({ to: "/" }) + } + if (!search.address?.trim()) { + throw redirect({ to: "/login" }) + } + }, + component: RecoverPassphrase, + head: () => ({ + meta: [{ title: "Recover passphrase" }], + }), +}) + +function RecoverPassphrase() { + const navigate = useNavigate() + const { address } = Route.useSearch() + const [proofMode, setProofMode] = useState("seed") + const [submitError, setSubmitError] = useState(null) + const [unavailable, setUnavailable] = useState(false) + + const form = useForm({ + resolver: zodResolver(formSchema), + mode: "onBlur", + defaultValues: { + newPassphrase: "", + confirmPassphrase: "", + seed: "", + privateKey: "", + }, + }) + + const onSubmit = async (data: FormData) => { + setSubmitError(null) + setUnavailable(false) + try { + await recoverWalletFromSeed({ + address: address.trim(), + new_passphrase: data.newPassphrase, + seed: proofMode === "seed" ? data.seed?.trim() : null, + private_key: proofMode === "hex" ? data.privateKey?.trim() : null, + }) + markLoginPassphraseRecoverySuccess() + await navigate({ to: "/login" }) + } catch (error) { + const apiError = error as ApiError + if (apiError?.status === 503) { + setUnavailable(true) + setSubmitError( + extractErrorMessage(apiError) || + "Passphrase recovery is not available on this node.", + ) + return + } + if (apiError?.status === 429) { + setSubmitError("Too many attempts. Wait a few minutes, then try again.") + return + } + if (apiError?.status === 401) { + setSubmitError( + "That seed phrase or private key does not match this wallet.", + ) + return + } + setSubmitError(extractErrorMessage(apiError)) + } + } + + return ( + +
+ +
+

Reset wallet passphrase

+

+ Prove you own this wallet, then choose a new passphrase. +

+

+ {truncateAddress(address)} +

+ + Back to unlock + +
+ +
+
+ +

+ This replaces the passphrase for this wallet on this node. You + will need the new passphrase to unlock. +

+
+
+ + {unavailable ? ( +
+

Recovery unavailable

+

{submitError}

+
+ ) : null} + + { + const mode = value as PassphraseRecoveryProofMode + setProofMode(mode) + setSubmitError(null) + form.setValue("seed", "") + form.setValue("privateKey", "") + }} + > + + + Seed phrase + + + Private key + + + + ( + + Seed phrase + +