diff --git a/app/api/routes/admin.py b/app/api/routes/admin.py index c045cac..0ee85b6 100644 --- a/app/api/routes/admin.py +++ b/app/api/routes/admin.py @@ -1,10 +1,14 @@ from fastapi import APIRouter, Depends, Header, HTTPException, status from pydantic import BaseModel +from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession +from app.audit.log import write_audit_log from app.config import settings +from app.db.models import User from app.db.session import get_session from app.rounds.config import get_round_config +from app.wallet.hd import derive_user_wif router = APIRouter(prefix="/admin", tags=["admin"]) @@ -42,3 +46,48 @@ async def update_config( config.bet_amount_sats = body.bet_amount_sats await session.commit() return RoundConfigResponse(fee_address=config.fee_address, bet_amount_sats=config.bet_amount_sats) + + +class AdminUserResponse(BaseModel): + id: int + username: str + address: str + balance_sats: int + created_at: str + + +@router.get("/users", response_model=list[AdminUserResponse], dependencies=[Depends(require_admin)]) +async def list_users(session: AsyncSession = Depends(get_session)) -> list[AdminUserResponse]: + users = (await session.scalars(select(User).order_by(User.id))).all() + return [ + AdminUserResponse( + id=u.id, + username=u.username, + address=u.address, + balance_sats=u.cached_balance_sats, + created_at=u.created_at.isoformat(), + ) + for u in users + ] + + +class AdminPrivkeyResponse(BaseModel): + address: str + wif: str + + +@router.get( + "/users/{user_id}/privkey", response_model=AdminPrivkeyResponse, dependencies=[Depends(require_admin)] +) +async def user_privkey(user_id: int, session: AsyncSession = Depends(get_session)) -> AdminPrivkeyResponse: + """Exports a user's raw private key for manual intervention (e.g. sweeping + funds back if something's stuck). Every access is audit-logged since this is + the most sensitive data the platform holds.""" + user = await session.get(User, user_id) + if user is None: + raise HTTPException(status.HTTP_404_NOT_FOUND, "user not found") + + wif = derive_user_wif(user.derivation_index) + await write_audit_log(session, "admin_privkey_accessed", {"user_id": user_id}, user_id=user_id) + await session.commit() + return AdminPrivkeyResponse(address=user.address, wif=wif) diff --git a/app/wallet/hd.py b/app/wallet/hd.py index b752f64..bbffd48 100644 --- a/app/wallet/hd.py +++ b/app/wallet/hd.py @@ -2,6 +2,7 @@ import os from embit import script from embit.bip32 import HDKey +from embit.ec import PrivateKey from app.config import settings from app.wallet.keystore import decrypt_xprv, encrypt_xprv @@ -39,6 +40,16 @@ def derive_user_address(derivation_index: int) -> str: return script.p2wpkh(pub).address(network=PLM_MAINNET) +def derive_user_wif(derivation_index: int) -> str: + """Exports a user's raw private key (WIF) for manual server-side intervention + (e.g. sweeping funds back to a user, or out, if something gets stuck). This is + a custodial system — the server already holds the master key this is derived + from — but callers must still treat the result as a live secret: log access, + never persist it, never return it over an unauthenticated channel.""" + key = derive_user_key(derivation_index) + return PrivateKey(key.secret, compressed=True, network=PLM_MAINNET).wif(network=PLM_MAINNET) + + def derive_pool_key() -> HDKey: """The "indirizzo padre" from the flowchart: all bets are sent here, and payouts are signed with this key. Reserved on branch 1 of the account (branch 0 diff --git a/tests/unit/test_admin.py b/tests/unit/test_admin.py index df9c633..8e6829e 100644 --- a/tests/unit/test_admin.py +++ b/tests/unit/test_admin.py @@ -1,4 +1,5 @@ import pytest +from cryptography.fernet import Fernet from httpx import ASGITransport, AsyncClient from app.config import settings @@ -9,6 +10,13 @@ async def client(monkeypatch, tmp_path): monkeypatch.setattr(settings, "database_url", f"sqlite+aiosqlite:///{tmp_path}/test.db") monkeypatch.setattr(settings, "admin_token", "test-admin-token") monkeypatch.setattr(settings, "jwt_secret", "test-jwt-secret") + monkeypatch.setattr(settings, "xprv_encryption_key", Fernet.generate_key().decode()) + monkeypatch.setattr(settings, "master_key_path", str(tmp_path / "master.xprv.enc")) + + import app.wallet.hd as hd + + hd._account_key = None + hd.generate_master_key() from sqlalchemy.ext.asyncio import create_async_engine @@ -19,6 +27,15 @@ async def client(monkeypatch, tmp_path): db_base.AsyncSessionLocal = async_sessionmaker(db_base.engine, expire_on_commit=False) + # app.db.session did `from app.db.base import AsyncSessionLocal` at its own + # first import, which only copies the reference as it was at that moment — + # reassigning db_base.AsyncSessionLocal above doesn't reach it. get_session() + # looks up its module global at call time, so rebinding it here (every test) + # keeps it pointed at *this* test's engine instead of whichever ran first. + from app.db import session as db_session + + db_session.AsyncSessionLocal = db_base.AsyncSessionLocal + async with db_base.engine.begin() as conn: await conn.run_sync(db_base.Base.metadata.create_all) @@ -62,3 +79,67 @@ async def test_admin_reads_and_updates_config(client): resp = await client.get("/admin/config", headers=headers) assert resp.json()["fee_address"] == "plm1qfeeaddress" + + +async def test_admin_lists_users(client): + from app.db import base as db_base + from app.db.models import User + from app.wallet.hd import derive_user_address + + async with db_base.AsyncSessionLocal() as session: + session.add( + User( + username="alice", + password_hash="unused", + derivation_index=0, + address=derive_user_address(0), + cached_balance_sats=1_000_000_000, + ) + ) + await session.commit() + + headers = {"X-Admin-Token": "test-admin-token"} + resp = await client.get("/admin/users", headers=headers) + assert resp.status_code == 200 + body = resp.json() + assert len(body) == 1 + assert body[0]["username"] == "alice" + assert body[0]["balance_sats"] == 1_000_000_000 + + +async def test_admin_exports_user_privkey(client): + from embit.ec import PrivateKey + + from app.db import base as db_base + from app.db.models import User + from app.wallet.hd import derive_user_address, derive_user_key + from app.wallet.plm_network import PLM_MAINNET + + async with db_base.AsyncSessionLocal() as session: + user = User( + username="bob", + password_hash="unused", + derivation_index=1, + address=derive_user_address(1), + ) + session.add(user) + await session.commit() + await session.refresh(user) + user_id = user.id + + headers = {"X-Admin-Token": "test-admin-token"} + resp = await client.get(f"/admin/users/{user_id}/privkey", headers=headers) + assert resp.status_code == 200 + body = resp.json() + + expected_wif = PrivateKey(derive_user_key(1).secret, compressed=True, network=PLM_MAINNET).wif( + network=PLM_MAINNET + ) + assert body["wif"] == expected_wif + assert body["address"] == derive_user_address(1) + + +async def test_admin_privkey_404_for_unknown_user(client): + headers = {"X-Admin-Token": "test-admin-token"} + resp = await client.get("/admin/users/999/privkey", headers=headers) + assert resp.status_code == 404