54884f3e24
Feed-health pass over KOL_FEEDS: - raoulpal: stale Substack (last 2024-05) → Real Vision podcast feed - dampedspring: paywalled (0 entries) → free "Damped Spring 101" Substack - unchained: Cloudflare 403 → canonical Megaphone podcast feed - lynalden: Cloudflare 202 → FeedBurner mirror - glassnode: recovered via httpx http2=True (was 403 on HTTP/1.1) - browser User-Agent + Accept headers on feed fetch - removed dead feeds with no active replacement: placeholder, dragonfly, niccarter, eugene - pin h2==4.3.0 (required by http2=True) All 25 remaining feeds verified fetching real body content; newest post per feed ≤88d. Bundles in-flight KOL-module work already in the working tree (kol_x ingest, migration 027, tests). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
147 lines
5.1 KiB
Python
147 lines
5.1 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
|