Files
trumpsignal-backend/app/services/signed_request.py
T

202 lines
7.5 KiB
Python

"""
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: <sha256_hex_of_canonical_json, or "-" for empty>
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)