""" Signed-request verification: EIP-191 signature that binds {action, wallet, timestamp, body}. Message format (human-readable — what the user sees in MetaMask): TrumpSignal · {ACTION} wallet: 0x... timestamp: 1713724800000 body: Server verifies: 1. recovered signer == wallet 2. |now - timestamp| <= MAX_SKEW_SECONDS (5 min) 3. sha256(canonical_json(body)) matches the hash in the message 4. signature not seen before (replay cache, TTL = MAX_SKEW) Same scheme is used for writes (with body) and reads (body=None → "-"). """ import hashlib import json import logging import time from typing import Any, Optional from eth_account import Account from eth_account.messages import encode_defunct from fastapi import HTTPException logger = logging.getLogger(__name__) MAX_SKEW_SECONDS = 300 # 5 minutes def canonical_body_hash(body: Optional[Any]) -> str: """Stable sha256 of a Python dict/primitive. `None` → '-'.""" if body is None: return "-" payload = json.dumps(body, sort_keys=True, separators=(",", ":"), default=str) return hashlib.sha256(payload.encode("utf-8")).hexdigest() def build_message(action: str, wallet: str, timestamp_ms: int, body_hash: str) -> str: return ( f"TrumpSignal · {action}\n" f"wallet: {wallet.lower()}\n" f"timestamp: {timestamp_ms}\n" f"body: {body_hash}" ) # ── Replay cache ────────────────────────────────────────────────────────── # In-memory; fine for a single-process deploy. For multi-worker, move to Redis. _seen: dict[str, float] = {} # sha256(signature) → expiry epoch seconds # Purge when the dict exceeds this size. At 5 minutes TTL and ~10 req/s peak # that's ~3000 entries at saturation; 1000 gives comfortable headroom while # keeping purge cost tiny (<1 ms for 1000-entry linear scan). _SEEN_PURGE_THRESHOLD = 1000 def _cache_put(sig: str, ttl: float) -> bool: """Return True if sig was new, False if replay.""" now = time.time() # Lazy purge: only scan when we've crossed the threshold, not every call. # Scanning 1000 vs 5000 entries keeps individual request latency negligible # even under burst traffic. if len(_seen) >= _SEEN_PURGE_THRESHOLD: expired = [k for k, v in _seen.items() if v < now] for k in expired: del _seen[k] key = hashlib.sha256(sig.encode("utf-8")).hexdigest() if key in _seen and _seen[key] > now: return False _seen[key] = now + ttl return True # ── Public API ──────────────────────────────────────────────────────────── def verify_signed_request( *, action: str, wallet: str, timestamp_ms: int, signature: str, body: Optional[Any], allow_replay: bool = False, ) -> None: """Raise HTTPException(401/422) if invalid. Silent on success. allow_replay=True skips the nonce cache — use only for idempotent reads (e.g. GET /user). Timestamp expiry still bounds the window to MAX_SKEW. """ wallet = wallet.lower().strip() # 1. Freshness now_ms = int(time.time() * 1000) if timestamp_ms > now_ms + 30_000 or now_ms - timestamp_ms > MAX_SKEW_SECONDS * 1000: # allow 30s future drift for clock skew raise HTTPException(401, "Signed request expired or clock skew too large") # 2. Recover signer body_hash = canonical_body_hash(body) message = build_message(action, wallet, timestamp_ms, body_hash) try: recovered = Account.recover_message(encode_defunct(text=message), signature=signature).lower() except Exception as exc: logger.warning("Signature recovery failed (%s): %s", action, exc) raise HTTPException(401, "Signature verification failed") if recovered != wallet: raise HTTPException(401, "Signature does not match wallet") # 3. Replay guard (writes only) if not allow_replay: if not _cache_put(signature, ttl=MAX_SKEW_SECONDS): raise HTTPException(401, "Signature already used (replay blocked)") def verify_signed_request_any( *, actions: list[str], wallet: str, timestamp_ms: int, signature: str, body: Optional[Any], allow_replay: bool = False, ) -> None: """Accept any one of several signed read-actions. Useful for wallet-owned read endpoints where the frontend may already hold a fresh cached envelope for a sibling read action such as `view_user`. """ last_exc: Optional[HTTPException] = None for action in actions: try: verify_signed_request( action=action, wallet=wallet, timestamp_ms=timestamp_ms, signature=signature, body=body, allow_replay=allow_replay, ) return except HTTPException as exc: last_exc = exc if last_exc is not None: raise last_exc # ── Signed-read credential extraction (C3) ──────────────────────────────── # Read endpoints historically took the signature as `?ts=&sig=` query params, # which leaks signatures into access logs / proxies / browser history. The # canonical transport is now the X-Sig-Ts / X-Sig-Sig HEADERS; the query # params are kept as a deprecated fallback so older clients keep working. from dataclasses import dataclass from fastapi import Header, Query @dataclass class SignedReadCreds: ts: int sig: str def signed_read_creds( ts: Optional[int] = Query(default=None, deprecated=True, description="DEPRECATED — use X-Sig-Ts header"), sig: Optional[str] = Query(default=None, deprecated=True, description="DEPRECATED — use X-Sig-Sig header"), x_sig_ts: Optional[int] = Header(default=None, alias="X-Sig-Ts", description="Signed timestamp (ms)"), x_sig_sig: Optional[str] = Header(default=None, alias="X-Sig-Sig", description="EIP-191 signature"), ) -> SignedReadCreds: """FastAPI dependency: required signed-read credentials. Headers win over query params when both are present.""" t = x_sig_ts if x_sig_ts is not None else ts s = x_sig_sig if x_sig_sig else sig if t is None or not s: raise HTTPException( 422, "Missing signed-read credentials (X-Sig-Ts / X-Sig-Sig headers)") return SignedReadCreds(ts=t, sig=s) def optional_signed_read_creds( # Legacy query names used by /telegram/{wallet}/status. timestamp: Optional[int] = Query(default=None, deprecated=True, description="DEPRECATED — use X-Sig-Ts header"), signature: Optional[str] = Query(default=None, deprecated=True, description="DEPRECATED — use X-Sig-Sig header"), x_sig_ts: Optional[int] = Header(default=None, alias="X-Sig-Ts"), x_sig_sig: Optional[str] = Header(default=None, alias="X-Sig-Sig"), ) -> Optional[SignedReadCreds]: """Like signed_read_creds but returns None when absent — for endpoints that serve a redacted response to unauthenticated callers.""" t = x_sig_ts if x_sig_ts is not None else timestamp s = x_sig_sig if x_sig_sig else signature if t is None or not s: return None return SignedReadCreds(ts=t, sig=s)