mirror of
https://github.com/HyPnoTiiK/stream-fusion.git
synced 2026-07-29 23:39:14 +00:00
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.
112 lines
3.6 KiB
Python
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)
|