Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
14 changes: 14 additions & 0 deletions octobot/community/authentication.py
Original file line number Diff line number Diff line change
Expand Up @@ -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)

Expand Down
4 changes: 4 additions & 0 deletions octobot/community/wallet_backend/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 (
Expand All @@ -54,6 +56,8 @@
"CannotRemoveAdminWalletError",
"InvalidPrivateKeyError",
"PassphraseTooShortError",
"WalletProofMismatchError",
"WalletStorageReadOnlyError",
"WalletStorage",
"ConfigJsonWalletStorage",
"DedicatedFileWalletStorage",
Expand Down
71 changes: 71 additions & 0 deletions octobot/community/wallet_backend/community_wallet.py
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,8 @@
WalletAlreadyExistsError,
WalletError,
WalletNotFoundError,
WalletProofMismatchError,
WalletStorageReadOnlyError,
)
from octobot.community.wallet_backend.wallet_storage import (
WalletStorage,
Expand Down Expand Up @@ -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
8 changes: 8 additions & 0 deletions octobot/community/wallet_backend/errors.py
Original file line number Diff line number Diff line change
Expand Up @@ -49,3 +49,11 @@ class InvalidPrivateKeyError(WalletError):

class PassphraseTooShortError(WalletError):
pass


class WalletProofMismatchError(WalletError):
pass


class WalletStorageReadOnlyError(WalletError):
pass
143 changes: 143 additions & 0 deletions packages/commons/octobot_commons/in_process_rate_limit.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
# Drakkar-Software OctoBot-Commons
# Copyright (c) Drakkar-Software, All rights reserved.
#
# This library is free software; you can redistribute it and/or
# modify it under the terms of the GNU Lesser General Public
# License as published by the Free Software Foundation; either
# version 3.0 of the License, or (at your option) any later version.
#
# This library 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
# Lesser General Public License for more details.
#
# You should have received a copy of the GNU Lesser General Public
# License along with this library.

import dataclasses
import threading
import time
import typing

# RAM-only counters in this process. Not shared across workers, pods, or nodes.


@dataclasses.dataclass(frozen=True)
class FailureWindowPolicy:
"""One failure-count window for a named request dimension (e.g. client IP)."""

name: str
max_failures: int
window_seconds: float
normalize_key: typing.Callable[[str], str] = lambda key: key


@dataclasses.dataclass
class _FailureBucket:
failure_count: int = 0
window_start: float = 0.0


class InProcessFailureRateLimiter:
"""In-process, multi-dimension failure rate limiter (fixed window per key)."""

def __init__(self, policies: tuple[FailureWindowPolicy, ...]) -> None:
if not policies:
raise ValueError("At least one FailureWindowPolicy is required")
self._policies = policies
self._lock = threading.Lock()
self._stores: dict[str, dict[str, _FailureBucket]] = {
policy.name: {} for policy in policies
}

def reset_all(self) -> None:
"""Clear all failure counters for every policy dimension."""
with self._lock:
for store in self._stores.values():
store.clear()

def _policy_value(self, policy: FailureWindowPolicy, **dimensions: str) -> str:
try:
raw = dimensions[policy.name]
except KeyError:
raise KeyError(
f"Missing rate-limit dimension '{policy.name}'"
) from None
return policy.normalize_key(raw)

def _is_limited_for_policy(
self,
policy: FailureWindowPolicy,
key: str,
now: float,
) -> bool:
store = self._stores[policy.name]
bucket = store.get(key)
if bucket is None or now - bucket.window_start >= policy.window_seconds:
store[key] = _FailureBucket(failure_count=0, window_start=now)
return False
return bucket.failure_count >= policy.max_failures

def _remaining_seconds_for_policy(
self,
policy: FailureWindowPolicy,
key: str,
now: float,
) -> float:
"""Read-only: seconds until this policy's window ends, or 0 if not limited."""
store = self._stores[policy.name]
bucket = store.get(key)
if bucket is None:
return 0.0
if now - bucket.window_start >= policy.window_seconds:
return 0.0
if bucket.failure_count < policy.max_failures:
return 0.0
remaining = bucket.window_start + policy.window_seconds - now
if remaining <= 0.0:
return 0.0
return remaining

def retry_after_seconds(self, **dimensions: str) -> float:
"""Monotonic seconds until the strictest active limit expires; 0 if not limited."""
now = time.monotonic()
max_remaining = 0.0
with self._lock:
for policy in self._policies:
key = self._policy_value(policy, **dimensions)
remaining = self._remaining_seconds_for_policy(policy, key, now)
max_remaining = max(max_remaining, remaining)
return max_remaining

def is_rate_limited(self, **dimensions: str) -> bool:
"""Return True when any policy dimension has reached its failure budget."""
now = time.monotonic()
with self._lock:
for policy in self._policies:
key = self._policy_value(policy, **dimensions)
if self._is_limited_for_policy(policy, key, now):
return True
return False

def record_failure(self, **dimensions: str) -> None:
"""Increment failure counts for all policy dimensions."""
now = time.monotonic()
with self._lock:
for policy in self._policies:
key = self._policy_value(policy, **dimensions)
store = self._stores[policy.name]
bucket = store.get(key)
if (
bucket is None
or now - bucket.window_start >= policy.window_seconds
):
bucket = _FailureBucket(failure_count=0, window_start=now)
store[key] = bucket
bucket.failure_count += 1

def record_success(self, **dimensions: str) -> None:
"""Clear failure counters for the given dimension keys."""
with self._lock:
for policy in self._policies:
key = self._policy_value(policy, **dimensions)
self._stores[policy.name].pop(key, None)
Loading
Loading