feat: add foreign key constraints and metrics for blockchain node
This commit is contained in:
@ -1,11 +1,14 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Any, Dict, Optional
|
||||
|
||||
from fastapi import APIRouter, Depends
|
||||
|
||||
from .deps import get_receipt_service
|
||||
from .deps import get_receipt_service, get_keystore, get_ledger
|
||||
from .models import ReceiptVerificationModel, from_validation_result
|
||||
from .keystore.service import KeystoreService
|
||||
from .ledger_mock import SQLiteLedgerAdapter
|
||||
from .receipts.service import ReceiptVerifierService
|
||||
|
||||
router = APIRouter(tags=["jsonrpc"])
|
||||
@ -24,6 +27,8 @@ def _response(result: Optional[Dict[str, Any]] = None, error: Optional[Dict[str,
|
||||
def handle_jsonrpc(
|
||||
request: Dict[str, Any],
|
||||
service: ReceiptVerifierService = Depends(get_receipt_service),
|
||||
keystore: KeystoreService = Depends(get_keystore),
|
||||
ledger: SQLiteLedgerAdapter = Depends(get_ledger),
|
||||
) -> Dict[str, Any]:
|
||||
method = request.get("method")
|
||||
params = request.get("params") or {}
|
||||
@ -46,4 +51,68 @@ def handle_jsonrpc(
|
||||
results = [from_validation_result(item).model_dump() for item in service.verify_history(str(job_id))]
|
||||
return _response(result={"items": results}, request_id=request_id)
|
||||
|
||||
if method == "wallet.list":
|
||||
items = []
|
||||
for record in keystore.list_records():
|
||||
ledger_record = ledger.get_wallet(record.wallet_id)
|
||||
metadata = ledger_record.metadata if ledger_record else record.metadata
|
||||
items.append({"wallet_id": record.wallet_id, "public_key": record.public_key, "metadata": metadata})
|
||||
return _response(result={"items": items}, request_id=request_id)
|
||||
|
||||
if method == "wallet.create":
|
||||
wallet_id = params.get("wallet_id")
|
||||
password = params.get("password")
|
||||
metadata = params.get("metadata") or {}
|
||||
secret_b64 = params.get("secret_key")
|
||||
if not wallet_id or not password:
|
||||
return _response(error={"code": -32602, "message": "wallet_id and password required"}, request_id=request_id)
|
||||
secret = base64.b64decode(secret_b64) if secret_b64 else None
|
||||
record = keystore.create_wallet(wallet_id=wallet_id, password=password, secret=secret, metadata=metadata)
|
||||
ledger.upsert_wallet(record.wallet_id, record.public_key, record.metadata)
|
||||
ledger.record_event(record.wallet_id, "created", {"metadata": record.metadata})
|
||||
return _response(
|
||||
result={
|
||||
"wallet": {
|
||||
"wallet_id": record.wallet_id,
|
||||
"public_key": record.public_key,
|
||||
"metadata": record.metadata,
|
||||
}
|
||||
},
|
||||
request_id=request_id,
|
||||
)
|
||||
|
||||
if method == "wallet.unlock":
|
||||
wallet_id = params.get("wallet_id")
|
||||
password = params.get("password")
|
||||
if not wallet_id or not password:
|
||||
return _response(error={"code": -32602, "message": "wallet_id and password required"}, request_id=request_id)
|
||||
try:
|
||||
keystore.unlock_wallet(wallet_id, password)
|
||||
ledger.record_event(wallet_id, "unlocked", {"success": True})
|
||||
return _response(result={"wallet_id": wallet_id, "unlocked": True}, request_id=request_id)
|
||||
except (KeyError, ValueError):
|
||||
ledger.record_event(wallet_id, "unlocked", {"success": False})
|
||||
return _response(error={"code": -32001, "message": "invalid credentials"}, request_id=request_id)
|
||||
|
||||
if method == "wallet.sign":
|
||||
wallet_id = params.get("wallet_id")
|
||||
password = params.get("password")
|
||||
message_b64 = params.get("message")
|
||||
if not wallet_id or not password or not message_b64:
|
||||
return _response(error={"code": -32602, "message": "wallet_id, password, message required"}, request_id=request_id)
|
||||
try:
|
||||
message = base64.b64decode(message_b64)
|
||||
except Exception:
|
||||
return _response(error={"code": -32602, "message": "invalid base64 message"}, request_id=request_id)
|
||||
|
||||
try:
|
||||
signature = keystore.sign_message(wallet_id, password, message)
|
||||
ledger.record_event(wallet_id, "sign", {"success": True})
|
||||
except (KeyError, ValueError):
|
||||
ledger.record_event(wallet_id, "sign", {"success": False})
|
||||
return _response(error={"code": -32001, "message": "invalid credentials"}, request_id=request_id)
|
||||
|
||||
signature_b64 = base64.b64encode(signature).decode()
|
||||
return _response(result={"wallet_id": wallet_id, "signature": signature_b64}, request_id=request_id)
|
||||
|
||||
return _response(error={"code": -32601, "message": "Method not found"}, request_id=request_id)
|
||||
|
||||
@ -1,18 +1,52 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status
|
||||
import base64
|
||||
|
||||
from .deps import get_receipt_service
|
||||
import logging
|
||||
import base64
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, status, Request
|
||||
|
||||
from .deps import get_receipt_service, get_keystore, get_ledger
|
||||
from .models import (
|
||||
ReceiptVerificationListResponse,
|
||||
ReceiptVerificationModel,
|
||||
ReceiptVerifyResponse,
|
||||
SignatureValidationModel,
|
||||
WalletCreateRequest,
|
||||
WalletCreateResponse,
|
||||
WalletListResponse,
|
||||
WalletUnlockRequest,
|
||||
WalletUnlockResponse,
|
||||
WalletSignRequest,
|
||||
WalletSignResponse,
|
||||
WalletDescriptor,
|
||||
from_validation_result,
|
||||
)
|
||||
from .keystore.service import KeystoreService
|
||||
from .ledger_mock import SQLiteLedgerAdapter
|
||||
from .receipts.service import ReceiptValidationResult, ReceiptVerifierService
|
||||
from .security import RateLimiter, wipe_buffer
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["receipts"])
|
||||
logger = logging.getLogger(__name__)
|
||||
_rate_limiter = RateLimiter(max_requests=30, window_seconds=60)
|
||||
|
||||
|
||||
def _rate_key(action: str, request: Request, wallet_id: Optional[str] = None) -> str:
|
||||
host = request.client.host if request.client else "unknown"
|
||||
parts = [action, host]
|
||||
if wallet_id:
|
||||
parts.append(wallet_id)
|
||||
return ":".join(parts)
|
||||
|
||||
|
||||
def _enforce_limit(action: str, request: Request, wallet_id: Optional[str] = None) -> None:
|
||||
key = _rate_key(action, request, wallet_id)
|
||||
if not _rate_limiter.allow(key):
|
||||
raise HTTPException(status_code=status.HTTP_429_TOO_MANY_REQUESTS, detail="rate limit exceeded")
|
||||
|
||||
|
||||
router = APIRouter(prefix="/v1", tags=["wallets", "receipts"])
|
||||
|
||||
|
||||
def _result_to_response(result: ReceiptValidationResult) -> ReceiptVerifyResponse:
|
||||
@ -47,3 +81,97 @@ def verify_receipt_history(
|
||||
results = service.verify_history(job_id)
|
||||
items = [from_validation_result(result) for result in results]
|
||||
return ReceiptVerificationListResponse(items=items)
|
||||
|
||||
|
||||
@router.get("/wallets", response_model=WalletListResponse, summary="List wallets")
|
||||
def list_wallets(
|
||||
keystore: KeystoreService = Depends(get_keystore),
|
||||
ledger: SQLiteLedgerAdapter = Depends(get_ledger),
|
||||
) -> WalletListResponse:
|
||||
descriptors = []
|
||||
for record in keystore.list_records():
|
||||
ledger_record = ledger.get_wallet(record.wallet_id)
|
||||
metadata = ledger_record.metadata if ledger_record else record.metadata
|
||||
descriptors.append(
|
||||
WalletDescriptor(wallet_id=record.wallet_id, public_key=record.public_key, metadata=metadata)
|
||||
)
|
||||
|
||||
|
||||
@router.post("/wallets", response_model=WalletCreateResponse, status_code=status.HTTP_201_CREATED, summary="Create wallet")
|
||||
def create_wallet(
|
||||
request: WalletCreateRequest,
|
||||
http_request: Request,
|
||||
keystore: KeystoreService = Depends(get_keystore),
|
||||
ledger: SQLiteLedgerAdapter = Depends(get_ledger),
|
||||
) -> WalletCreateResponse:
|
||||
_enforce_limit("wallet-create", http_request)
|
||||
|
||||
try:
|
||||
secret = base64.b64decode(request.secret_key) if request.secret_key else None
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid base64 secret") from exc
|
||||
|
||||
try:
|
||||
record = keystore.create_wallet(
|
||||
wallet_id=request.wallet_id,
|
||||
password=request.password,
|
||||
secret=secret,
|
||||
metadata=request.metadata,
|
||||
)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail=str(exc)) from exc
|
||||
|
||||
ledger.upsert_wallet(record.wallet_id, record.public_key, record.metadata)
|
||||
ledger.record_event(record.wallet_id, "created", {"metadata": record.metadata})
|
||||
logger.info("Created wallet", extra={"wallet_id": record.wallet_id})
|
||||
wallet = WalletDescriptor(wallet_id=record.wallet_id, public_key=record.public_key, metadata=record.metadata)
|
||||
return WalletCreateResponse(wallet=wallet)
|
||||
|
||||
|
||||
@router.post("/wallets/{wallet_id}/unlock", response_model=WalletUnlockResponse, summary="Unlock wallet")
|
||||
def unlock_wallet(
|
||||
wallet_id: str,
|
||||
request: WalletUnlockRequest,
|
||||
http_request: Request,
|
||||
keystore: KeystoreService = Depends(get_keystore),
|
||||
ledger: SQLiteLedgerAdapter = Depends(get_ledger),
|
||||
) -> WalletUnlockResponse:
|
||||
_enforce_limit("wallet-unlock", http_request, wallet_id)
|
||||
try:
|
||||
secret = bytearray(keystore.unlock_wallet(wallet_id, request.password))
|
||||
ledger.record_event(wallet_id, "unlocked", {"success": True})
|
||||
logger.info("Unlocked wallet", extra={"wallet_id": wallet_id})
|
||||
except (KeyError, ValueError):
|
||||
ledger.record_event(wallet_id, "unlocked", {"success": False})
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials")
|
||||
finally:
|
||||
if "secret" in locals():
|
||||
wipe_buffer(secret)
|
||||
# We don't expose the secret in response
|
||||
return WalletUnlockResponse(wallet_id=wallet_id, unlocked=True)
|
||||
|
||||
|
||||
@router.post("/wallets/{wallet_id}/sign", response_model=WalletSignResponse, summary="Sign payload")
|
||||
def sign_payload(
|
||||
wallet_id: str,
|
||||
request: WalletSignRequest,
|
||||
http_request: Request,
|
||||
keystore: KeystoreService = Depends(get_keystore),
|
||||
ledger: SQLiteLedgerAdapter = Depends(get_ledger),
|
||||
) -> WalletSignResponse:
|
||||
_enforce_limit("wallet-sign", http_request, wallet_id)
|
||||
try:
|
||||
message = base64.b64decode(request.message_base64)
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=status.HTTP_400_BAD_REQUEST, detail="invalid base64 message") from exc
|
||||
|
||||
try:
|
||||
signature = keystore.sign_message(wallet_id, request.password, message)
|
||||
ledger.record_event(wallet_id, "sign", {"success": True})
|
||||
logger.debug("Signed payload", extra={"wallet_id": wallet_id})
|
||||
except (KeyError, ValueError):
|
||||
ledger.record_event(wallet_id, "sign", {"success": False})
|
||||
raise HTTPException(status_code=status.HTTP_401_UNAUTHORIZED, detail="invalid credentials")
|
||||
|
||||
signature_b64 = base64.b64encode(signature).decode()
|
||||
return WalletSignResponse(wallet_id=wallet_id, signature_base64=signature_b64)
|
||||
|
||||
@ -9,6 +9,8 @@ from nacl.bindings import (
|
||||
crypto_aead_xchacha20poly1305_ietf_encrypt,
|
||||
)
|
||||
|
||||
from ..security import wipe_buffer
|
||||
|
||||
|
||||
class EncryptionError(Exception):
|
||||
"""Raised when encryption or decryption fails."""
|
||||
@ -50,13 +52,15 @@ class EncryptionSuite:
|
||||
raise EncryptionError("encryption failed") from exc
|
||||
|
||||
def decrypt(self, *, password: str, ciphertext: bytes, salt: bytes, nonce: bytes) -> bytes:
|
||||
key = self._derive_key(password=password, salt=salt)
|
||||
key_bytes = bytearray(self._derive_key(password=password, salt=salt))
|
||||
try:
|
||||
return crypto_aead_xchacha20poly1305_ietf_decrypt(
|
||||
ciphertext=ciphertext,
|
||||
aad=b"",
|
||||
nonce=nonce,
|
||||
key=key,
|
||||
key=bytes(key_bytes),
|
||||
)
|
||||
except Exception as exc:
|
||||
raise EncryptionError("decryption failed") from exc
|
||||
finally:
|
||||
wipe_buffer(key_bytes)
|
||||
|
||||
@ -5,6 +5,7 @@ from functools import lru_cache
|
||||
from fastapi import Depends
|
||||
|
||||
from .keystore.service import KeystoreService
|
||||
from .ledger_mock import SQLiteLedgerAdapter
|
||||
from .receipts.service import ReceiptVerifierService
|
||||
from .settings import Settings, settings
|
||||
|
||||
@ -24,3 +25,8 @@ def get_receipt_service(config: Settings = Depends(get_settings)) -> ReceiptVeri
|
||||
@lru_cache
|
||||
def get_keystore() -> KeystoreService:
|
||||
return KeystoreService()
|
||||
|
||||
|
||||
@lru_cache
|
||||
def get_ledger(config: Settings = Depends(get_settings)) -> SQLiteLedgerAdapter:
|
||||
return SQLiteLedgerAdapter(config.ledger_db_path)
|
||||
|
||||
@ -1,16 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from typing import Dict, List, Optional
|
||||
from typing import Dict, Iterable, List, Optional
|
||||
|
||||
from secrets import token_bytes
|
||||
|
||||
from nacl.signing import SigningKey
|
||||
|
||||
from ..crypto.encryption import EncryptionSuite, EncryptionError
|
||||
from ..security import validate_password_rules, wipe_buffer
|
||||
|
||||
|
||||
@dataclass
|
||||
class WalletRecord:
|
||||
wallet_id: str
|
||||
public_key: str
|
||||
salt: bytes
|
||||
nonce: bytes
|
||||
ciphertext: bytes
|
||||
@ -27,14 +31,46 @@ class KeystoreService:
|
||||
def list_wallets(self) -> List[str]:
|
||||
return list(self._wallets.keys())
|
||||
|
||||
def list_records(self) -> Iterable[WalletRecord]:
|
||||
return list(self._wallets.values())
|
||||
|
||||
def get_wallet(self, wallet_id: str) -> Optional[WalletRecord]:
|
||||
return self._wallets.get(wallet_id)
|
||||
|
||||
def create_wallet(self, wallet_id: str, password: str, plaintext: bytes, metadata: Optional[Dict[str, str]] = None) -> WalletRecord:
|
||||
def create_wallet(
|
||||
self,
|
||||
wallet_id: str,
|
||||
password: str,
|
||||
secret: Optional[bytes] = None,
|
||||
metadata: Optional[Dict[str, str]] = None,
|
||||
) -> WalletRecord:
|
||||
if wallet_id in self._wallets:
|
||||
raise ValueError("wallet already exists")
|
||||
|
||||
validate_password_rules(password)
|
||||
|
||||
metadata_map = {str(k): str(v) for k, v in (metadata or {}).items()}
|
||||
|
||||
if secret is None:
|
||||
signing_key = SigningKey.generate()
|
||||
secret_bytes = signing_key.encode()
|
||||
else:
|
||||
if len(secret) != SigningKey.seed_size:
|
||||
raise ValueError("secret key must be 32 bytes")
|
||||
secret_bytes = secret
|
||||
signing_key = SigningKey(secret_bytes)
|
||||
|
||||
salt = token_bytes(self._encryption.salt_bytes)
|
||||
nonce = token_bytes(self._encryption.nonce_bytes)
|
||||
ciphertext = self._encryption.encrypt(password=password, plaintext=plaintext, salt=salt, nonce=nonce)
|
||||
record = WalletRecord(wallet_id=wallet_id, salt=salt, nonce=nonce, ciphertext=ciphertext, metadata=metadata or {})
|
||||
ciphertext = self._encryption.encrypt(password=password, plaintext=secret_bytes, salt=salt, nonce=nonce)
|
||||
record = WalletRecord(
|
||||
wallet_id=wallet_id,
|
||||
public_key=signing_key.verify_key.encode().hex(),
|
||||
salt=salt,
|
||||
nonce=nonce,
|
||||
ciphertext=ciphertext,
|
||||
metadata=metadata_map,
|
||||
)
|
||||
self._wallets[wallet_id] = record
|
||||
return record
|
||||
|
||||
@ -49,3 +85,12 @@ class KeystoreService:
|
||||
|
||||
def delete_wallet(self, wallet_id: str) -> bool:
|
||||
return self._wallets.pop(wallet_id, None) is not None
|
||||
|
||||
def sign_message(self, wallet_id: str, password: str, message: bytes) -> bytes:
|
||||
secret_bytes = bytearray(self.unlock_wallet(wallet_id, password))
|
||||
try:
|
||||
signing_key = SigningKey(bytes(secret_bytes))
|
||||
signed = signing_key.sign(message)
|
||||
return signed.signature
|
||||
finally:
|
||||
wipe_buffer(secret_bytes)
|
||||
|
||||
3
apps/wallet-daemon/src/app/ledger_mock/__init__.py
Normal file
3
apps/wallet-daemon/src/app/ledger_mock/__init__.py
Normal file
@ -0,0 +1,3 @@
|
||||
from .sqlite_adapter import SQLiteLedgerAdapter, WalletRecord, WalletEvent
|
||||
|
||||
__all__ = ["SQLiteLedgerAdapter", "WalletRecord", "WalletEvent"]
|
||||
106
apps/wallet-daemon/src/app/ledger_mock/sqlite_adapter.py
Normal file
106
apps/wallet-daemon/src/app/ledger_mock/sqlite_adapter.py
Normal file
@ -0,0 +1,106 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import json
|
||||
import sqlite3
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
from typing import Iterable, Optional
|
||||
|
||||
|
||||
@dataclass
|
||||
class WalletRecord:
|
||||
wallet_id: str
|
||||
public_key: str
|
||||
metadata: dict
|
||||
|
||||
|
||||
@dataclass
|
||||
class WalletEvent:
|
||||
wallet_id: str
|
||||
event_type: str
|
||||
payload: dict
|
||||
|
||||
|
||||
class SQLiteLedgerAdapter:
|
||||
def __init__(self, db_path: Path) -> None:
|
||||
self._db_path = db_path
|
||||
self._ensure_schema()
|
||||
|
||||
def _connect(self) -> sqlite3.Connection:
|
||||
conn = sqlite3.connect(self._db_path)
|
||||
conn.row_factory = sqlite3.Row
|
||||
return conn
|
||||
|
||||
def _ensure_schema(self) -> None:
|
||||
self._db_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS wallets (
|
||||
wallet_id TEXT PRIMARY KEY,
|
||||
public_key TEXT NOT NULL,
|
||||
metadata TEXT NOT NULL
|
||||
)
|
||||
"""
|
||||
)
|
||||
conn.execute(
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS wallet_events (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
wallet_id TEXT NOT NULL,
|
||||
event_type TEXT NOT NULL,
|
||||
payload TEXT NOT NULL,
|
||||
created_at DATETIME DEFAULT CURRENT_TIMESTAMP,
|
||||
FOREIGN KEY(wallet_id) REFERENCES wallets(wallet_id)
|
||||
)
|
||||
"""
|
||||
)
|
||||
|
||||
def upsert_wallet(self, wallet_id: str, public_key: str, metadata: dict) -> None:
|
||||
payload = json.dumps(metadata)
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"""
|
||||
INSERT INTO wallets(wallet_id, public_key, metadata)
|
||||
VALUES (?, ?, ?)
|
||||
ON CONFLICT(wallet_id) DO UPDATE SET public_key=excluded.public_key, metadata=excluded.metadata
|
||||
""",
|
||||
(wallet_id, public_key, payload),
|
||||
)
|
||||
|
||||
def get_wallet(self, wallet_id: str) -> Optional[WalletRecord]:
|
||||
with self._connect() as conn:
|
||||
row = conn.execute(
|
||||
"SELECT wallet_id, public_key, metadata FROM wallets WHERE wallet_id = ?",
|
||||
(wallet_id,),
|
||||
).fetchone()
|
||||
if row is None:
|
||||
return None
|
||||
return WalletRecord(wallet_id=row["wallet_id"], public_key=row["public_key"], metadata=json.loads(row["metadata"]))
|
||||
|
||||
def list_wallets(self) -> Iterable[WalletRecord]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute("SELECT wallet_id, public_key, metadata FROM wallets").fetchall()
|
||||
for row in rows:
|
||||
yield WalletRecord(wallet_id=row["wallet_id"], public_key=row["public_key"], metadata=json.loads(row["metadata"]))
|
||||
|
||||
def record_event(self, wallet_id: str, event_type: str, payload: dict) -> None:
|
||||
data = json.dumps(payload)
|
||||
with self._connect() as conn:
|
||||
conn.execute(
|
||||
"INSERT INTO wallet_events(wallet_id, event_type, payload) VALUES (?, ?, ?)",
|
||||
(wallet_id, event_type, data),
|
||||
)
|
||||
|
||||
def list_events(self, wallet_id: str) -> Iterable[WalletEvent]:
|
||||
with self._connect() as conn:
|
||||
rows = conn.execute(
|
||||
"SELECT wallet_id, event_type, payload FROM wallet_events WHERE wallet_id = ? ORDER BY id",
|
||||
(wallet_id,),
|
||||
).fetchall()
|
||||
for row in rows:
|
||||
yield WalletEvent(
|
||||
wallet_id=row["wallet_id"],
|
||||
event_type=row["event_type"],
|
||||
payload=json.loads(row["payload"]),
|
||||
)
|
||||
@ -1,6 +1,6 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import List
|
||||
from typing import Any, Dict, List, Optional
|
||||
|
||||
from aitbc_sdk import SignatureValidation
|
||||
|
||||
@ -43,3 +43,43 @@ def from_validation_result(result) -> ReceiptVerificationModel:
|
||||
|
||||
class ReceiptVerificationListResponse(BaseModel):
|
||||
items: List[ReceiptVerificationModel]
|
||||
|
||||
|
||||
class WalletDescriptor(BaseModel):
|
||||
wallet_id: str
|
||||
public_key: str
|
||||
metadata: Dict[str, Any]
|
||||
|
||||
|
||||
class WalletListResponse(BaseModel):
|
||||
items: List[WalletDescriptor]
|
||||
|
||||
|
||||
class WalletCreateRequest(BaseModel):
|
||||
wallet_id: str
|
||||
password: str
|
||||
metadata: Dict[str, Any] = {}
|
||||
secret_key: Optional[str] = None
|
||||
|
||||
|
||||
class WalletCreateResponse(BaseModel):
|
||||
wallet: WalletDescriptor
|
||||
|
||||
|
||||
class WalletUnlockRequest(BaseModel):
|
||||
password: str
|
||||
|
||||
|
||||
class WalletUnlockResponse(BaseModel):
|
||||
wallet_id: str
|
||||
unlocked: bool
|
||||
|
||||
|
||||
class WalletSignRequest(BaseModel):
|
||||
password: str
|
||||
message_base64: str
|
||||
|
||||
|
||||
class WalletSignResponse(BaseModel):
|
||||
wallet_id: str
|
||||
signature_base64: str
|
||||
|
||||
43
apps/wallet-daemon/src/app/security.py
Normal file
43
apps/wallet-daemon/src/app/security.py
Normal file
@ -0,0 +1,43 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
import threading
|
||||
import time
|
||||
from collections import defaultdict, deque
|
||||
|
||||
|
||||
class RateLimiter:
|
||||
def __init__(self, max_requests: int = 30, window_seconds: int = 60) -> None:
|
||||
self._max_requests = max_requests
|
||||
self._window_seconds = window_seconds
|
||||
self._lock = threading.Lock()
|
||||
self._records: dict[str, deque[float]] = defaultdict(deque)
|
||||
|
||||
def allow(self, key: str) -> bool:
|
||||
now = time.monotonic()
|
||||
with self._lock:
|
||||
entries = self._records[key]
|
||||
while entries and now - entries[0] > self._window_seconds:
|
||||
entries.popleft()
|
||||
if len(entries) >= self._max_requests:
|
||||
return False
|
||||
entries.append(now)
|
||||
return True
|
||||
|
||||
|
||||
def validate_password_rules(password: str) -> None:
|
||||
if len(password) < 12:
|
||||
raise ValueError("password must be at least 12 characters long")
|
||||
if not re.search(r"[A-Z]", password):
|
||||
raise ValueError("password must include at least one uppercase letter")
|
||||
if not re.search(r"[a-z]", password):
|
||||
raise ValueError("password must include at least one lowercase letter")
|
||||
if not re.search(r"\d", password):
|
||||
raise ValueError("password must include at least one digit")
|
||||
if not re.search(r"[^A-Za-z0-9]", password):
|
||||
raise ValueError("password must include at least one symbol")
|
||||
|
||||
|
||||
def wipe_buffer(buffer: bytearray) -> None:
|
||||
for index in range(len(buffer)):
|
||||
buffer[index] = 0
|
||||
@ -1,5 +1,7 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from pydantic import Field
|
||||
from pydantic_settings import BaseSettings
|
||||
|
||||
@ -14,6 +16,7 @@ class Settings(BaseSettings):
|
||||
coordinator_api_key: str = Field(default="client_dev_key_1", alias="COORDINATOR_API_KEY")
|
||||
|
||||
rest_prefix: str = Field(default="/v1", alias="REST_PREFIX")
|
||||
ledger_db_path: Path = Field(default=Path("./data/wallet_ledger.db"), alias="LEDGER_DB_PATH")
|
||||
|
||||
class Config:
|
||||
env_file = ".env"
|
||||
|
||||
38
apps/wallet-daemon/tests/test_ledger.py
Normal file
38
apps/wallet-daemon/tests/test_ledger.py
Normal file
@ -0,0 +1,38 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
|
||||
from app.ledger_mock import SQLiteLedgerAdapter
|
||||
|
||||
|
||||
def test_upsert_and_get_wallet(tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "ledger.db"
|
||||
adapter = SQLiteLedgerAdapter(db_path)
|
||||
|
||||
adapter.upsert_wallet("wallet-1", "pubkey", {"label": "primary"})
|
||||
|
||||
record = adapter.get_wallet("wallet-1")
|
||||
assert record is not None
|
||||
assert record.wallet_id == "wallet-1"
|
||||
assert record.public_key == "pubkey"
|
||||
assert record.metadata["label"] == "primary"
|
||||
|
||||
# Update metadata and ensure persistence
|
||||
adapter.upsert_wallet("wallet-1", "pubkey", {"label": "updated"})
|
||||
updated = adapter.get_wallet("wallet-1")
|
||||
assert updated is not None
|
||||
assert updated.metadata["label"] == "updated"
|
||||
|
||||
|
||||
def test_event_ordering(tmp_path: Path) -> None:
|
||||
db_path = tmp_path / "ledger.db"
|
||||
adapter = SQLiteLedgerAdapter(db_path)
|
||||
|
||||
adapter.upsert_wallet("wallet-1", "pubkey", {})
|
||||
adapter.record_event("wallet-1", "created", {"step": 1})
|
||||
adapter.record_event("wallet-1", "unlock", {"step": 2})
|
||||
adapter.record_event("wallet-1", "sign", {"step": 3})
|
||||
|
||||
events = list(adapter.list_events("wallet-1"))
|
||||
assert [event.event_type for event in events] == ["created", "unlock", "sign"]
|
||||
assert [event.payload["step"] for event in events] == [1, 2, 3]
|
||||
82
apps/wallet-daemon/tests/test_wallet_api.py
Normal file
82
apps/wallet-daemon/tests/test_wallet_api.py
Normal file
@ -0,0 +1,82 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
|
||||
import pytest
|
||||
from fastapi.testclient import TestClient
|
||||
|
||||
from aitbc_chain.app import create_app # noqa: I100
|
||||
|
||||
from app.deps import get_keystore, get_ledger
|
||||
|
||||
|
||||
@pytest.fixture(name="client")
|
||||
def client_fixture(tmp_path, monkeypatch):
|
||||
# Override ledger path to temporary directory
|
||||
from app.settings import Settings
|
||||
|
||||
class TestSettings(Settings):
|
||||
ledger_db_path = tmp_path / "ledger.db"
|
||||
|
||||
monkeypatch.setattr("app.deps.get_settings", lambda: TestSettings())
|
||||
|
||||
app = create_app()
|
||||
return TestClient(app)
|
||||
|
||||
|
||||
def _create_wallet(client: TestClient, wallet_id: str, password: str = "Password!234") -> None:
|
||||
payload = {
|
||||
"wallet_id": wallet_id,
|
||||
"password": password,
|
||||
}
|
||||
response = client.post("/v1/wallets", json=payload)
|
||||
assert response.status_code == 201, response.text
|
||||
|
||||
|
||||
def test_wallet_workflow(client: TestClient):
|
||||
wallet_id = "wallet-1"
|
||||
password = "StrongPass!234"
|
||||
|
||||
# Create wallet
|
||||
response = client.post(
|
||||
"/v1/wallets",
|
||||
json={
|
||||
"wallet_id": wallet_id,
|
||||
"password": password,
|
||||
"metadata": {"label": "test"},
|
||||
},
|
||||
)
|
||||
assert response.status_code == 201, response.text
|
||||
data = response.json()["wallet"]
|
||||
assert data["wallet_id"] == wallet_id
|
||||
assert "public_key" in data
|
||||
|
||||
# List wallets
|
||||
response = client.get("/v1/wallets")
|
||||
assert response.status_code == 200
|
||||
items = response.json()["items"]
|
||||
assert any(item["wallet_id"] == wallet_id for item in items)
|
||||
|
||||
# Unlock wallet
|
||||
response = client.post(f"/v1/wallets/{wallet_id}/unlock", json={"password": password})
|
||||
assert response.status_code == 200
|
||||
assert response.json()["unlocked"] is True
|
||||
|
||||
# Sign payload
|
||||
message = base64.b64encode(b"hello").decode()
|
||||
response = client.post(
|
||||
f"/v1/wallets/{wallet_id}/sign",
|
||||
json={"password": password, "message_base64": message},
|
||||
)
|
||||
assert response.status_code == 200, response.text
|
||||
signature = response.json()["signature_base64"]
|
||||
assert isinstance(signature, str) and len(signature) > 0
|
||||
|
||||
|
||||
def test_wallet_password_rules(client: TestClient):
|
||||
response = client.post(
|
||||
"/v1/wallets",
|
||||
json={"wallet_id": "weak", "password": "short"},
|
||||
)
|
||||
assert response.status_code == 400
|
||||
***
|
||||
Reference in New Issue
Block a user