done
This commit is contained in:
@@ -0,0 +1,107 @@
|
||||
"""
|
||||
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
|
||||
|
||||
|
||||
def _cache_put(sig: str, ttl: float) -> bool:
|
||||
"""Return True if sig was new, False if replay."""
|
||||
now = time.time()
|
||||
# lazy purge
|
||||
if len(_seen) > 5000:
|
||||
for k, v in list(_seen.items()):
|
||||
if v < now:
|
||||
_seen.pop(k, None)
|
||||
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 abs(now_ms - timestamp_ms) > MAX_SKEW_SECONDS * 1000:
|
||||
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)")
|
||||
Reference in New Issue
Block a user