stream-fusion-better/stream_fusion/utils/peer/crypto.py
LimeDrive 0aba81abb2 feat(peer): add authenticated peer-to-peer cache sharing
Introduces a secure peer-to-peer cache sharing system using HMAC-SHA256 request signatures and Fernet (AES-128-CBC) response encryption.

- New peer_keys table for per-peer credential management (key_id, secret)
- New /api/peer/check endpoint returns encrypted debrid availability data
- New /api/peer/items endpoint returns encrypted torrent metadata
- Rate limiting per peer key with configurable limits and windows
- Admin UI for creating, viewing, revoking, and deleting peer keys
- Peer cache enrichment integrated into BaseDebrid L2.5 lookup pipeline
- StremThruDebrid override with cross-service PG L2 check + peer cache
- Removed old /api/share/cache endpoint (replaced by /api/peer/*)
- Added cryptography package dependency

BREAKING CHANGE: The /api/share/cache endpoint has been removed and replaced with /api/peer/check. Clients must update to use HMAC authentication with X-Peer-Key-Id, X-Peer-Timestamp, and X-Peer-Signature headers.
2026-03-28 05:07:40 +01:00

112 lines
3.6 KiB
Python

"""Cryptographic utilities for peer-to-peer cache authentication.
Security model:
- Each peer relationship uses a shared 256-bit secret (stored in peer_keys table).
- HMAC-SHA256 with a timestamp is used to authenticate every request.
A ±60 second window prevents replay attacks.
- Fernet (AES-128-CBC + HMAC-SHA256) is used to encrypt responses so that
data (including file names and torrent metadata) is only readable by the
holder of the corresponding secret.
- The Fernet key is deterministically derived from the secret via SHA-256
so no additional key material needs to be stored or exchanged.
"""
import base64
import hashlib
import hmac as _hmac
import json
import time
from typing import Any
def derive_fernet_key(secret: str) -> bytes:
"""Derive a Fernet-compatible 32-byte key from a shared secret.
Uses SHA-256 with a fixed domain prefix so the same secret cannot be
reused across different contexts without separation.
"""
raw = hashlib.sha256(f"sf-peer-cache-v1:{secret}".encode()).digest()
return base64.urlsafe_b64encode(raw)
def sign_request(secret: str, body_bytes: bytes) -> tuple[str, str]:
"""Sign a request body with HMAC-SHA256.
The signature covers: timestamp + "." + SHA-256(body).
This ties the signature to both the body content and the moment of creation.
Returns:
(timestamp_str, hmac_hex)
"""
ts = str(int(time.time()))
msg = ts.encode() + b"." + hashlib.sha256(body_bytes).digest()
sig = _hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
return ts, sig
def verify_request(
secret: str,
timestamp: str,
signature: str,
body_bytes: bytes,
max_age: int = 60,
) -> bool:
"""Verify an HMAC-SHA256 request signature and timestamp freshness.
Args:
secret: The shared peer secret.
timestamp: Value of the X-Peer-Timestamp header.
signature: Value of the X-Peer-Signature header (hex-encoded HMAC).
body_bytes: Raw request body bytes.
max_age: Maximum age in seconds (default 60 — replay protection).
Returns:
True if the signature is valid and the timestamp is fresh.
Returns False on *any* failure to prevent timing oracle attacks.
"""
try:
if abs(time.time() - int(timestamp)) > max_age:
return False
msg = timestamp.encode() + b"." + hashlib.sha256(body_bytes).digest()
expected = _hmac.new(secret.encode(), msg, hashlib.sha256).hexdigest()
return _hmac.compare_digest(expected, signature)
except Exception:
return False
def encrypt_payload(secret: str, data: dict[str, Any]) -> str:
"""Fernet-encrypt a dict payload.
Args:
secret: The shared peer secret (used to derive the Fernet key).
data: Arbitrary dict to serialize and encrypt.
Returns:
URL-safe Fernet token string.
"""
from cryptography.fernet import Fernet
key = derive_fernet_key(secret)
token = Fernet(key).encrypt(json.dumps(data, default=str).encode())
return token.decode()
def decrypt_payload(secret: str, token: str) -> dict[str, Any]:
"""Decrypt a Fernet token produced by encrypt_payload.
Args:
secret: The shared peer secret.
token: Fernet token string.
Returns:
The decrypted dict.
Raises:
cryptography.fernet.InvalidToken: if the token is invalid or tampered.
json.JSONDecodeError: if the decrypted payload is not valid JSON.
"""
from cryptography.fernet import Fernet
key = derive_fernet_key(secret)
raw = Fernet(key).decrypt(token.encode())
return json.loads(raw)