From c984a1e052ee74e91f781419881aaaba67056cd8 Mon Sep 17 00:00:00 2001 From: oib Date: Fri, 13 Feb 2026 16:07:03 +0100 Subject: [PATCH] chore: enhance security configuration across applications - Add root-level *.json to .gitignore to prevent wallet backup leaks - Replace wildcard CORS origins with explicit localhost URLs across all apps - Add OPTIONS method to CORS allowed methods for preflight requests - Update coordinator database to use absolute path in data/ directory to prevent duplicates - Add JWT secret validation in coordinator config (must be set via environment) - Replace deprecated get_session dependency with Session --- .gitignore | 3 + apps/blockchain-node/src/aitbc_chain/app.py | 9 +- .../src/aitbc_chain/gossip/relay.py | 11 +- apps/coordinator-api/src/app/config.py | 27 +- apps/coordinator-api/src/app/config_pg.py | 12 +- apps/coordinator-api/src/app/deps.py | 14 +- apps/coordinator-api/src/app/main.py | 5 +- apps/coordinator-api/src/app/routers/users.py | 12 +- apps/coordinator-api/src/app/storage/db_pg.py | 29 +- apps/trade-exchange/exchange_api.py | 139 +++++++++- cli/aitbc_cli/commands/wallet.py | 249 +++++++++++++----- cli/aitbc_cli/utils/__init__.py | 34 ++- my-wallet_backup_20260212_174208.json | 10 - 13 files changed, 434 insertions(+), 120 deletions(-) delete mode 100644 my-wallet_backup_20260212_174208.json diff --git a/.gitignore b/.gitignore index 17a19226..7268aeaf 100644 --- a/.gitignore +++ b/.gitignore @@ -150,6 +150,9 @@ home/client/client_wallet.json home/genesis_wallet.json home/miner/miner_wallet.json +# Root-level wallet backups (contain private keys) +*.json + # =================== # Stale source copies # =================== diff --git a/apps/blockchain-node/src/aitbc_chain/app.py b/apps/blockchain-node/src/aitbc_chain/app.py index 57f23dd9..de7d501e 100644 --- a/apps/blockchain-node/src/aitbc_chain/app.py +++ b/apps/blockchain-node/src/aitbc_chain/app.py @@ -111,8 +111,13 @@ def create_app() -> FastAPI: app.add_middleware(RateLimitMiddleware, max_requests=200, window_seconds=60) app.add_middleware( CORSMiddleware, - allow_origins=["*"], - allow_methods=["GET", "POST"], + allow_origins=[ + "http://localhost:3000", + "http://localhost:8080", + "http://localhost:8000", + "http://localhost:8011" + ], + allow_methods=["GET", "POST", "OPTIONS"], allow_headers=["*"], ) diff --git a/apps/blockchain-node/src/aitbc_chain/gossip/relay.py b/apps/blockchain-node/src/aitbc_chain/gossip/relay.py index 008c9c92..1e9cf74b 100644 --- a/apps/blockchain-node/src/aitbc_chain/gossip/relay.py +++ b/apps/blockchain-node/src/aitbc_chain/gossip/relay.py @@ -70,7 +70,16 @@ def create_app() -> Starlette: ] middleware = [ - Middleware(CORSMiddleware, allow_origins=["*"], allow_methods=["*"]) + Middleware( + CORSMiddleware, + allow_origins=[ + "http://localhost:3000", + "http://localhost:8080", + "http://localhost:8000", + "http://localhost:8011" + ], + allow_methods=["POST", "GET", "OPTIONS"] + ) ] return Starlette(routes=routes, middleware=middleware) diff --git a/apps/coordinator-api/src/app/config.py b/apps/coordinator-api/src/app/config.py index cdb545bb..ceb76b52 100644 --- a/apps/coordinator-api/src/app/config.py +++ b/apps/coordinator-api/src/app/config.py @@ -1,5 +1,7 @@ from pydantic_settings import BaseSettings, SettingsConfigDict from typing import List, Optional +from pathlib import Path +import os class Settings(BaseSettings): @@ -9,14 +11,35 @@ class Settings(BaseSettings): app_host: str = "127.0.0.1" app_port: int = 8011 - database_url: str = "sqlite:///./coordinator.db" + # Use absolute path to avoid database duplicates in different working directories + @property + def database_url(self) -> str: + # Find project root by looking for .git directory + current = Path(__file__).resolve() + while current.parent != current: + if (current / ".git").exists(): + project_root = current + break + current = current.parent + else: + # Fallback to relative path if .git not found + project_root = Path(__file__).resolve().parents[3] + + db_path = project_root / "data" / "coordinator.db" + db_path.parent.mkdir(parents=True, exist_ok=True) + return f"sqlite:///{db_path}" client_api_keys: List[str] = [] miner_api_keys: List[str] = [] admin_api_keys: List[str] = [] hmac_secret: Optional[str] = None - allow_origins: List[str] = ["*"] + allow_origins: List[str] = [ + "http://localhost:3000", + "http://localhost:8080", + "http://localhost:8000", + "http://localhost:8011" + ] job_ttl_seconds: int = 900 heartbeat_interval_seconds: int = 10 diff --git a/apps/coordinator-api/src/app/config_pg.py b/apps/coordinator-api/src/app/config_pg.py index 0a692315..117e812d 100644 --- a/apps/coordinator-api/src/app/config_pg.py +++ b/apps/coordinator-api/src/app/config_pg.py @@ -17,7 +17,7 @@ class Settings(BaseSettings): database_url: str = "postgresql://localhost:5432/aitbc_coordinator" # JWT Configuration - jwt_secret: str = "change-me-in-production" + jwt_secret: str = "" # Must be provided via environment jwt_algorithm: str = "HS256" jwt_expiration_hours: int = 24 @@ -51,7 +51,17 @@ class Settings(BaseSettings): class Config: env_file = ".env" env_file_encoding = "utf-8" + + def validate_secrets(self) -> None: + """Validate that all required secrets are provided""" + if not self.jwt_secret: + raise ValueError("JWT_SECRET environment variable is required") + if self.jwt_secret == "change-me-in-production": + raise ValueError("JWT_SECRET must be changed from default value") # Create global settings instance settings = Settings() + +# Validate secrets on import +settings.validate_secrets() diff --git a/apps/coordinator-api/src/app/deps.py b/apps/coordinator-api/src/app/deps.py index 98e7e26e..a19bbc49 100644 --- a/apps/coordinator-api/src/app/deps.py +++ b/apps/coordinator-api/src/app/deps.py @@ -1,21 +1,9 @@ -from typing import Callable, Generator, Annotated +from typing import Callable, Annotated from fastapi import Depends, Header, HTTPException -from sqlmodel import Session from .config import settings -def get_session() -> Generator[Session, None, None]: - """Get database session""" - from .database import engine - with Session(engine) as session: - yield session - - -# Type alias for session dependency -SessionDep = Annotated[Session, Depends(get_session)] - - class APIKeyValidator: def __init__(self, allowed_keys: list[str]): self.allowed_keys = {key.strip() for key in allowed_keys if key} diff --git a/apps/coordinator-api/src/app/main.py b/apps/coordinator-api/src/app/main.py index 127e87c7..85325b73 100644 --- a/apps/coordinator-api/src/app/main.py +++ b/apps/coordinator-api/src/app/main.py @@ -3,7 +3,6 @@ from fastapi.middleware.cors import CORSMiddleware from prometheus_client import make_asgi_app from .config import settings -from .database import create_db_and_tables from .storage import init_db from .routers import ( client, @@ -38,8 +37,8 @@ def create_app() -> FastAPI: CORSMiddleware, allow_origins=settings.allow_origins, allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"] + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["*"] # Allow all headers for API keys and content types ) app.include_router(client, prefix="/v1") diff --git a/apps/coordinator-api/src/app/routers/users.py b/apps/coordinator-api/src/app/routers/users.py index 16d384a3..a8e34235 100644 --- a/apps/coordinator-api/src/app/routers/users.py +++ b/apps/coordinator-api/src/app/routers/users.py @@ -10,7 +10,7 @@ import time import hashlib from datetime import datetime, timedelta -from ..deps import get_session +from ..storage import SessionDep from ..domain import User, Wallet from ..schemas import UserCreate, UserLogin, UserProfile, UserBalance @@ -50,7 +50,7 @@ def verify_session_token(token: str) -> Optional[str]: @router.post("/register", response_model=UserProfile) async def register_user( user_data: UserCreate, - session: Session = Depends(get_session) + session: SessionDep ) -> Dict[str, Any]: """Register a new user""" @@ -103,7 +103,7 @@ async def register_user( @router.post("/login", response_model=UserProfile) async def login_user( login_data: UserLogin, - session: Session = Depends(get_session) + session: SessionDep ) -> Dict[str, Any]: """Login user with wallet address""" @@ -161,7 +161,7 @@ async def login_user( @router.get("/users/me", response_model=UserProfile) async def get_current_user( token: str, - session: Session = Depends(get_session) + session: SessionDep ) -> Dict[str, Any]: """Get current user profile""" @@ -190,7 +190,7 @@ async def get_current_user( @router.get("/users/{user_id}/balance", response_model=UserBalance) async def get_user_balance( user_id: str, - session: Session = Depends(get_session) + session: SessionDep ) -> Dict[str, Any]: """Get user's AITBC balance""" @@ -223,7 +223,7 @@ async def logout_user(token: str) -> Dict[str, str]: @router.get("/users/{user_id}/transactions") async def get_user_transactions( user_id: str, - session: Session = Depends(get_session) + session: SessionDep ) -> Dict[str, Any]: """Get user's transaction history""" diff --git a/apps/coordinator-api/src/app/storage/db_pg.py b/apps/coordinator-api/src/app/storage/db_pg.py index 7377722b..5cdead4a 100644 --- a/apps/coordinator-api/src/app/storage/db_pg.py +++ b/apps/coordinator-api/src/app/storage/db_pg.py @@ -30,12 +30,16 @@ Base = declarative_base() # Direct PostgreSQL connection for performance def get_pg_connection(): """Get direct PostgreSQL connection""" + # Parse database URL from settings + from urllib.parse import urlparse + parsed = urlparse(settings.database_url) + return psycopg2.connect( - host="localhost", - database="aitbc_coordinator", - user="aitbc_user", - password="aitbc_password", - port=5432, + host=parsed.hostname or "localhost", + database=parsed.path[1:] if parsed.path else "aitbc_coordinator", + user=parsed.username or "aitbc_user", + password=parsed.password or "aitbc_password", + port=parsed.port or 5432, cursor_factory=RealDictCursor ) @@ -194,8 +198,16 @@ class PostgreSQLAdapter: if self.connection: self.connection.close() -# Global adapter instance -db_adapter = PostgreSQLAdapter() +# Global adapter instance (lazy initialization) +db_adapter: Optional[PostgreSQLAdapter] = None + + +def get_db_adapter() -> PostgreSQLAdapter: + """Get or create database adapter instance""" + global db_adapter + if db_adapter is None: + db_adapter = PostgreSQLAdapter() + return db_adapter # Database initialization def init_db(): @@ -212,7 +224,8 @@ def init_db(): def check_db_health() -> Dict[str, Any]: """Check database health""" try: - result = db_adapter.execute_query("SELECT 1 as health_check") + adapter = get_db_adapter() + result = adapter.execute_query("SELECT 1 as health_check") return { "status": "healthy", "database": "postgresql", diff --git a/apps/trade-exchange/exchange_api.py b/apps/trade-exchange/exchange_api.py index 41a2f7f3..3d3cdcb0 100644 --- a/apps/trade-exchange/exchange_api.py +++ b/apps/trade-exchange/exchange_api.py @@ -5,11 +5,14 @@ FastAPI backend for the AITBC Trade Exchange from datetime import datetime, timedelta from typing import List, Optional -from fastapi import FastAPI, Depends, HTTPException, status +from fastapi import FastAPI, Depends, HTTPException, status, Header from fastapi.middleware.cors import CORSMiddleware from pydantic import BaseModel from sqlalchemy import desc, func, and_ from sqlalchemy.orm import Session +import hashlib +import time +from typing import Annotated from database import init_db, get_db_session from models import User, Order, Trade, Balance @@ -17,13 +20,59 @@ from models import User, Order, Trade, Balance # Initialize FastAPI app app = FastAPI(title="AITBC Trade Exchange API", version="1.0.0") +# In-memory session storage (use Redis in production) +user_sessions = {} + +def verify_session_token(token: str = Header(..., alias="Authorization")) -> int: + """Verify session token and return user_id""" + # Remove "Bearer " prefix if present + if token.startswith("Bearer "): + token = token[7:] + + if token not in user_sessions: + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Invalid or expired token" + ) + + session = user_sessions[token] + + # Check if expired + if int(time.time()) > session["expires_at"]: + del user_sessions[token] + raise HTTPException( + status_code=status.HTTP_401_UNAUTHORIZED, + detail="Token expired" + ) + + return session["user_id"] + +def optional_auth(token: Optional[str] = Header(None, alias="Authorization")) -> Optional[int]: + """Optional authentication - returns user_id if token is valid, None otherwise""" + if not token: + return None + + try: + return verify_session_token(token) + except HTTPException: + return None + +# Type annotations for dependencies +UserDep = Annotated[int, Depends(verify_session_token)] +OptionalUserDep = Annotated[Optional[int], Depends(optional_auth)] + # Add CORS middleware app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=[ + "http://localhost:3000", + "http://localhost:8080", + "http://localhost:8000", + "http://localhost:3003" + ], allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=["GET", "POST", "PUT", "DELETE", "OPTIONS"], + allow_headers=["*"], # Allow all headers for auth tokens ) # Pydantic models @@ -110,6 +159,41 @@ def get_recent_trades(limit: int = 20, db: Session = Depends(get_db_session)): trades = db.query(Trade).order_by(desc(Trade.created_at)).limit(limit).all() return trades +@app.get("/api/orders", response_model=List[OrderResponse]) +def get_orders( + status_filter: Optional[str] = None, + user_only: bool = False, + db: Session = Depends(get_db_session), + user_id: OptionalUserDep = None +): + """Get all orders with optional status filter""" + query = db.query(Order) + + # Filter by user if requested and authenticated + if user_only and user_id: + query = query.filter(Order.user_id == user_id) + + if status_filter: + query = query.filter(Order.status == status_filter.upper()) + + orders = query.order_by(Order.created_at.desc()).all() + return orders + +@app.get("/api/my/orders", response_model=List[OrderResponse]) +def get_my_orders( + user_id: UserDep, + status_filter: Optional[str] = None, + db: Session = Depends(get_db_session) +): + """Get current user's orders""" + query = db.query(Order).filter(Order.user_id == user_id) + + if status_filter: + query = query.filter(Order.status == status_filter.upper()) + + orders = query.order_by(Order.created_at.desc()).all() + return orders + @app.get("/api/orders/orderbook", response_model=OrderBookResponse) def get_orderbook(db: Session = Depends(get_db_session)): """Get current order book""" @@ -127,7 +211,11 @@ def get_orderbook(db: Session = Depends(get_db_session)): return OrderBookResponse(buys=buys, sells=sells) @app.post("/api/orders", response_model=OrderResponse) -def create_order(order: OrderCreate, db: Session = Depends(get_db_session)): +def create_order( + order: OrderCreate, + db: Session = Depends(get_db_session), + user_id: UserDep +): """Create a new order""" # Validate order type @@ -140,7 +228,7 @@ def create_order(order: OrderCreate, db: Session = Depends(get_db_session)): # Create order total = order.amount * order.price db_order = Order( - user_id=1, # TODO: Get from authentication + user_id=user_id, # Use authenticated user_id order_type=order.order_type, amount=order.amount, price=order.price, @@ -219,6 +307,45 @@ def try_match_order(order: Order, db: Session): db.commit() +@app.post("/api/auth/login") +def login_user(wallet_address: str, db: Session = Depends(get_db_session)): + """Login with wallet address""" + # Find or create user + user = db.query(User).filter(User.wallet_address == wallet_address).first() + if not user: + user = User( + wallet_address=wallet_address, + email=f"{wallet_address}@aitbc.local", + is_active=True + ) + db.add(user) + db.commit() + db.refresh(user) + + # Create session token + token_data = f"{user.id}:{int(time.time())}" + token = hashlib.sha256(token_data.encode()).hexdigest() + + # Store session + user_sessions[token] = { + "user_id": user.id, + "created_at": int(time.time()), + "expires_at": int(time.time()) + 86400 # 24 hours + } + + return {"token": token, "user_id": user.id} + +@app.post("/api/auth/logout") +def logout_user(token: str = Header(..., alias="Authorization")): + """Logout user""" + if token.startswith("Bearer "): + token = token[7:] + + if token in user_sessions: + del user_sessions[token] + + return {"message": "Logged out successfully"} + @app.get("/api/health") def health_check(): """Health check endpoint""" diff --git a/cli/aitbc_cli/commands/wallet.py b/cli/aitbc_cli/commands/wallet.py index fc53dd08..d3b37d8d 100644 --- a/cli/aitbc_cli/commands/wallet.py +++ b/cli/aitbc_cli/commands/wallet.py @@ -9,7 +9,70 @@ import yaml from pathlib import Path from typing import Optional, Dict, Any, List from datetime import datetime, timedelta -from ..utils import output, error, success +from ..utils import output, error, success, encrypt_value, decrypt_value +import getpass + + +def _get_wallet_password(wallet_name: str) -> str: + """Get or prompt for wallet encryption password""" + # Try to get from keyring first + try: + import keyring + password = keyring.get_password("aitbc-wallet", wallet_name) + if password: + return password + except: + pass + + # Prompt for password + while True: + password = getpass.getpass(f"Enter password for wallet '{wallet_name}': ") + if not password: + error("Password cannot be empty") + continue + + confirm = getpass.getpass("Confirm password: ") + if password != confirm: + error("Passwords do not match") + continue + + # Store in keyring for future use + try: + import keyring + keyring.set_password("aitbc-wallet", wallet_name, password) + except: + pass + + return password + + +def _save_wallet(wallet_path: Path, wallet_data: Dict[str, Any], password: str = None): + """Save wallet with encrypted private key""" + # Encrypt private key if provided + if password and 'private_key' in wallet_data: + wallet_data['private_key'] = encrypt_value(wallet_data['private_key'], password) + wallet_data['encrypted'] = True + + # Save wallet + with open(wallet_path, 'w') as f: + json.dump(wallet_data, f, indent=2) + + +def _load_wallet(wallet_path: Path, wallet_name: str) -> Dict[str, Any]: + """Load wallet and decrypt private key if needed""" + with open(wallet_path, 'r') as f: + wallet_data = json.load(f) + + # Decrypt private key if encrypted + if wallet_data.get('encrypted') and 'private_key' in wallet_data: + password = _get_wallet_password(wallet_name) + try: + wallet_data['private_key'] = decrypt_value(wallet_data['private_key'], password) + except Exception: + error("Invalid password for wallet") + raise click.Abort() + + return wallet_data @click.group() @@ -56,8 +119,9 @@ def wallet(ctx, wallet_name: Optional[str], wallet_path: Optional[str]): @wallet.command() @click.argument('name') @click.option('--type', 'wallet_type', default='hd', help='Wallet type (hd, simple)') +@click.option('--no-encrypt', is_flag=True, help='Skip wallet encryption (not recommended)') @click.pass_context -def create(ctx, name: str, wallet_type: str): +def create(ctx, name: str, wallet_type: str, no_encrypt: bool): """Create a new wallet""" wallet_dir = ctx.obj['wallet_dir'] wallet_path = wallet_dir / f"{name}.json" @@ -70,10 +134,29 @@ def create(ctx, name: str, wallet_type: str): if wallet_type == 'hd': # Hierarchical Deterministic wallet import secrets - seed = secrets.token_hex(32) - address = f"aitbc1{seed[:40]}" - private_key = f"0x{seed}" - public_key = f"0x{secrets.token_hex(32)}" + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, NoEncryption, PrivateFormat + import base64 + + # Generate private key + private_key_bytes = secrets.token_bytes(32) + private_key = f"0x{private_key_bytes.hex()}" + + # Derive public key from private key using ECDSA + priv_key = ec.derive_private_key(int.from_bytes(private_key_bytes, 'big'), ec.SECP256K1()) + pub_key = priv_key.public_key() + pub_key_bytes = pub_key.public_bytes( + encoding=Encoding.X962, + format=PublicFormat.UncompressedPoint + ) + public_key = f"0x{pub_key_bytes.hex()}" + + # Generate address from public key (simplified) + digest = hashes.Hash(hashes.SHA256()) + digest.update(pub_key_bytes) + address_hash = digest.finalize() + address = f"aitbc1{address_hash[:20].hex()}" else: # Simple wallet import secrets @@ -92,9 +175,14 @@ def create(ctx, name: str, wallet_type: str): "transactions": [] } + # Get password for encryption unless skipped + password = None + if not no_encrypt: + success("Wallet encryption is enabled. Your private key will be encrypted at rest.") + password = _get_wallet_password(name) + # Save wallet - with open(wallet_path, 'w') as f: - json.dump(wallet_data, f, indent=2) + _save_wallet(wallet_path, wallet_data, password) success(f"Wallet '{name}' created successfully") output({ @@ -123,13 +211,16 @@ def list(ctx): for wallet_file in wallet_dir.glob("*.json"): with open(wallet_file, 'r') as f: wallet_data = json.load(f) - wallets.append({ + wallet_info = { "name": wallet_data['wallet_id'], "type": wallet_data.get('type', 'simple'), "address": wallet_data['address'], "created_at": wallet_data['created_at'], "active": wallet_data['wallet_id'] == active_wallet - }) + } + if wallet_data.get('encrypted'): + wallet_info['encrypted'] = True + wallets.append(wallet_info) output(wallets, ctx.obj.get('output_format', 'table')) @@ -163,9 +254,11 @@ def switch(ctx, name: str): yaml.dump(config, f, default_flow_style=False) success(f"Switched to wallet '{name}'") + # Load wallet to get address (will handle encryption) + wallet_data = _load_wallet(wallet_path, name) output({ "active_wallet": name, - "address": json.load(open(wallet_path))['address'] + "address": wallet_data['address'] }, ctx.obj.get('output_format', 'table')) @@ -255,7 +348,8 @@ def restore(ctx, backup_path: str, name: str, force: bool): wallet_data['wallet_id'] = name wallet_data['restored_at'] = datetime.utcnow().isoformat() + "Z" - # Save restored wallet + # Save restored wallet (preserve encryption state) + # If wallet was encrypted, we save it as-is (still encrypted with original password) with open(wallet_path, 'w') as f: json.dump(wallet_data, f, indent=2) @@ -279,8 +373,7 @@ def info(ctx): error(f"Wallet '{wallet_name}' not found. Use 'aitbc wallet create' to create one.") return - with open(wallet_path, 'r') as f: - wallet_data = json.load(f) + wallet_data = _load_wallet(wallet_path, wallet_name) # Get active wallet from config active_wallet = 'default' @@ -317,22 +410,46 @@ def balance(ctx): # Auto-create wallet if it doesn't exist if not wallet_path.exists(): import secrets + from cryptography.hazmat.primitives import hashes + from cryptography.hazmat.primitives.asymmetric import ec + from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat + + # Generate proper key pair + private_key_bytes = secrets.token_bytes(32) + private_key = f"0x{private_key_bytes.hex()}" + + # Derive public key from private key + priv_key = ec.derive_private_key(int.from_bytes(private_key_bytes, 'big'), ec.SECP256K1()) + pub_key = priv_key.public_key() + pub_key_bytes = pub_key.public_bytes( + encoding=Encoding.X962, + format=PublicFormat.UncompressedPoint + ) + public_key = f"0x{pub_key_bytes.hex()}" + + # Generate address from public key + digest = hashes.Hash(hashes.SHA256()) + digest.update(pub_key_bytes) + address_hash = digest.finalize() + address = f"aitbc1{address_hash[:20].hex()}" + wallet_data = { "wallet_id": wallet_name, "type": "simple", - "address": f"aitbc1{secrets.token_hex(20)}", - "public_key": f"0x{secrets.token_hex(32)}", - "private_key": f"0x{secrets.token_hex(32)}", + "address": address, + "public_key": public_key, + "private_key": private_key, "created_at": datetime.utcnow().isoformat() + "Z", "balance": 0.0, "transactions": [] } wallet_path.parent.mkdir(parents=True, exist_ok=True) - with open(wallet_path, 'w') as f: - json.dump(wallet_data, f, indent=2) + # Auto-create with encryption + success("Creating new wallet with encryption enabled") + password = _get_wallet_password(wallet_name) + _save_wallet(wallet_path, wallet_data, password) else: - with open(wallet_path, 'r') as f: - wallet_data = json.load(f) + wallet_data = _load_wallet(wallet_path, wallet_name) # Try to get balance from blockchain if available if config: @@ -377,8 +494,7 @@ def history(ctx, limit: int): error(f"Wallet '{wallet_name}' not found") return - with open(wallet_path, 'r') as f: - wallet_data = json.load(f) + wallet_data = _load_wallet(wallet_path, wallet_name) transactions = wallet_data.get('transactions', [])[-limit:] @@ -413,8 +529,7 @@ def earn(ctx, amount: float, job_id: str, desc: Optional[str]): error(f"Wallet '{wallet_name}' not found") return - with open(wallet_path, 'r') as f: - wallet_data = json.load(f) + wallet_data = _load_wallet(wallet_path, wallet_name) # Add transaction transaction = { @@ -428,9 +543,11 @@ def earn(ctx, amount: float, job_id: str, desc: Optional[str]): wallet_data['transactions'].append(transaction) wallet_data['balance'] = wallet_data.get('balance', 0) + amount - # Save wallet - with open(wallet_path, 'w') as f: - json.dump(wallet_data, f, indent=2) + # Save wallet with encryption + password = None + if wallet_data.get('encrypted'): + password = _get_wallet_password(wallet_name) + _save_wallet(wallet_path, wallet_data, password) success(f"Earnings added: {amount} AITBC") output({ @@ -454,8 +571,7 @@ def spend(ctx, amount: float, description: str): error(f"Wallet '{wallet_name}' not found") return - with open(wallet_path, 'r') as f: - wallet_data = json.load(f) + wallet_data = _load_wallet(wallet_path, wallet_name) balance = wallet_data.get('balance', 0) if balance < amount: @@ -474,9 +590,11 @@ def spend(ctx, amount: float, description: str): wallet_data['transactions'].append(transaction) wallet_data['balance'] = balance - amount - # Save wallet - with open(wallet_path, 'w') as f: - json.dump(wallet_data, f, indent=2) + # Save wallet with encryption + password = None + if wallet_data.get('encrypted'): + password = _get_wallet_password(wallet_name) + _save_wallet(wallet_path, wallet_data, password) success(f"Spent: {amount} AITBC") output({ @@ -498,8 +616,7 @@ def address(ctx): error(f"Wallet '{wallet_name}' not found") return - with open(wallet_path, 'r') as f: - wallet_data = json.load(f) + wallet_data = _load_wallet(wallet_path, wallet_name) output({ "wallet": wallet_name, @@ -522,8 +639,7 @@ def send(ctx, to_address: str, amount: float, description: Optional[str]): error(f"Wallet '{wallet_name}' not found") return - with open(wallet_path, 'r') as f: - wallet_data = json.load(f) + wallet_data = _load_wallet(wallet_path, wallet_name) balance = wallet_data.get('balance', 0) if balance < amount: @@ -589,8 +705,11 @@ def send(ctx, to_address: str, amount: float, description: Optional[str]): wallet_data['transactions'].append(transaction) wallet_data['balance'] = balance - amount - with open(wallet_path, 'w') as f: - json.dump(wallet_data, f, indent=2) + # Save wallet with encryption + password = None + if wallet_data.get('encrypted'): + password = _get_wallet_password(wallet_name) + _save_wallet(wallet_path, wallet_data, password) output({ "wallet": wallet_name, @@ -615,8 +734,7 @@ def request_payment(ctx, to_address: str, amount: float, description: Optional[s error(f"Wallet '{wallet_name}' not found") return - with open(wallet_path, 'r') as f: - wallet_data = json.load(f) + wallet_data = _load_wallet(wallet_path, wallet_name) # Create payment request request = { @@ -645,8 +763,7 @@ def stats(ctx): error(f"Wallet '{wallet_name}' not found") return - with open(wallet_path, 'r') as f: - wallet_data = json.load(f) + wallet_data = _load_wallet(wallet_path, wallet_name) transactions = wallet_data.get('transactions', []) @@ -680,8 +797,7 @@ def stake(ctx, amount: float, duration: int): error(f"Wallet '{wallet_name}' not found") return - with open(wallet_path, 'r') as f: - wallet_data = json.load(f) + wallet_data = _load_wallet(wallet_path, wallet_name) balance = wallet_data.get('balance', 0) if balance < amount: @@ -714,8 +830,11 @@ def stake(ctx, amount: float, duration: int): "timestamp": datetime.now().isoformat() }) - with open(wallet_path, 'w') as f: - json.dump(wallet_data, f, indent=2) + # Save wallet with encryption + password = None + if wallet_data.get('encrypted'): + password = _get_wallet_password(wallet_name) + _save_wallet(wallet_path, wallet_data, password) success(f"Staked {amount} AITBC for {duration} days") output({ @@ -774,8 +893,11 @@ def unstake(ctx, stake_id: str): "timestamp": datetime.now().isoformat() }) - with open(wallet_path, 'w') as f: - json.dump(wallet_data, f, indent=2) + # Save wallet with encryption + password = None + if wallet_data.get('encrypted'): + password = _get_wallet_password(wallet_name) + _save_wallet(wallet_path, wallet_data, password) success(f"Unstaked {stake_record['amount']} AITBC + {rewards:.4f} rewards") output({ @@ -800,8 +922,7 @@ def staking_info(ctx): error(f"Wallet '{wallet_name}' not found") return - with open(wallet_path, 'r') as f: - wallet_data = json.load(f) + wallet_data = _load_wallet(wallet_path, wallet_name) staking = wallet_data.get('staking', []) active_stakes = [s for s in staking if s['status'] == 'active'] @@ -995,14 +1116,14 @@ def multisig_sign(ctx, wallet_name: str, tx_id: str, signer: str): @click.pass_context def liquidity_stake(ctx, amount: float, pool: str, lock_days: int): """Stake tokens into a liquidity pool""" + wallet_name = ctx.obj['wallet_name'] wallet_path = ctx.obj.get('wallet_path') if not wallet_path or not Path(wallet_path).exists(): error("Wallet not found") ctx.exit(1) return - with open(wallet_path) as f: - wallet_data = json.load(f) + wallet_data = _load_wallet(Path(wallet_path), wallet_name) balance = wallet_data.get('balance', 0) if balance < amount: @@ -1051,8 +1172,11 @@ def liquidity_stake(ctx, amount: float, pool: str, lock_days: int): "timestamp": now.isoformat() }) - with open(wallet_path, "w") as f: - json.dump(wallet_data, f, indent=2) + # Save wallet with encryption + password = None + if wallet_data.get('encrypted'): + password = _get_wallet_password(wallet_name) + _save_wallet(Path(wallet_path), wallet_data, password) success(f"Staked {amount} AITBC into '{pool}' pool ({tier} tier, {apy}% APY)") output({ @@ -1071,14 +1195,14 @@ def liquidity_stake(ctx, amount: float, pool: str, lock_days: int): @click.pass_context def liquidity_unstake(ctx, stake_id: str): """Withdraw from a liquidity pool with rewards""" + wallet_name = ctx.obj['wallet_name'] wallet_path = ctx.obj.get('wallet_path') if not wallet_path or not Path(wallet_path).exists(): error("Wallet not found") ctx.exit(1) return - with open(wallet_path) as f: - wallet_data = json.load(f) + wallet_data = _load_wallet(Path(wallet_path), wallet_name) liquidity = wallet_data.get('liquidity', []) record = next((r for r in liquidity if r["stake_id"] == stake_id and r["status"] == "active"), None) @@ -1118,8 +1242,11 @@ def liquidity_unstake(ctx, stake_id: str): "timestamp": datetime.now().isoformat() }) - with open(wallet_path, "w") as f: - json.dump(wallet_data, f, indent=2) + # Save wallet with encryption + password = None + if wallet_data.get('encrypted'): + password = _get_wallet_password(wallet_name) + _save_wallet(Path(wallet_path), wallet_data, password) success(f"Withdrawn {total:.6f} AITBC (principal: {record['amount']}, rewards: {rewards:.6f})") output({ @@ -1138,14 +1265,14 @@ def liquidity_unstake(ctx, stake_id: str): @click.pass_context def rewards(ctx): """View all earned rewards (staking + liquidity)""" + wallet_name = ctx.obj['wallet_name'] wallet_path = ctx.obj.get('wallet_path') if not wallet_path or not Path(wallet_path).exists(): error("Wallet not found") ctx.exit(1) return - with open(wallet_path) as f: - wallet_data = json.load(f) + wallet_data = _load_wallet(Path(wallet_path), wallet_name) staking = wallet_data.get('staking', []) liquidity = wallet_data.get('liquidity', []) diff --git a/cli/aitbc_cli/utils/__init__.py b/cli/aitbc_cli/utils/__init__.py index 8e1a27ae..b2f55c8e 100644 --- a/cli/aitbc_cli/utils/__init__.py +++ b/cli/aitbc_cli/utils/__init__.py @@ -76,20 +76,40 @@ class AuditLogger: return entries[-limit:] -def encrypt_value(value: str, key: str = None) -> str: - """Simple XOR-based obfuscation for config values (not cryptographic security)""" +def _get_fernet_key(key: str = None) -> bytes: + """Derive a Fernet key from a password or use default""" + from cryptography.fernet import Fernet import base64 - key = key or "aitbc_config_key_2026" - encrypted = bytes([ord(c) ^ ord(key[i % len(key)]) for i, c in enumerate(value)]) + import hashlib + + if key is None: + # Use a default key (should be overridden in production) + key = "aitbc_config_key_2026_default" + + # Derive a 32-byte key suitable for Fernet + return base64.urlsafe_b64encode(hashlib.sha256(key.encode()).digest()) + + +def encrypt_value(value: str, key: str = None) -> str: + """Encrypt a value using Fernet symmetric encryption""" + from cryptography.fernet import Fernet + import base64 + + fernet_key = _get_fernet_key(key) + f = Fernet(fernet_key) + encrypted = f.encrypt(value.encode()) return base64.b64encode(encrypted).decode() def decrypt_value(encrypted: str, key: str = None) -> str: - """Decrypt an XOR-obfuscated config value""" + """Decrypt a Fernet-encrypted value""" + from cryptography.fernet import Fernet import base64 - key = key or "aitbc_config_key_2026" + + fernet_key = _get_fernet_key(key) + f = Fernet(fernet_key) data = base64.b64decode(encrypted) - return ''.join(chr(b ^ ord(key[i % len(key)])) for i, b in enumerate(data)) + return f.decrypt(data).decode() def setup_logging(verbosity: int, debug: bool = False) -> str: diff --git a/my-wallet_backup_20260212_174208.json b/my-wallet_backup_20260212_174208.json deleted file mode 100644 index 37da7cdc..00000000 --- a/my-wallet_backup_20260212_174208.json +++ /dev/null @@ -1,10 +0,0 @@ -{ - "wallet_id": "my-wallet", - "type": "hd", - "address": "aitbc1e9056b875c03773b067fd0345248abd3db762af5", - "public_key": "0x2619e06fca452e9a524a688ceccbbb07ebdaaa824c3e78c4a406ed2a60cee47f", - "private_key": "0xe9056b875c03773b067fd0345248abd3db762af5274c8e30ab304cb04a7b4c79", - "created_at": "2026-02-12T16:41:34.206767Z", - "balance": 0, - "transactions": [] -} \ No newline at end of file