169 lines
7.2 KiB
Python
169 lines
7.2 KiB
Python
from __future__ import annotations
|
||
|
||
import base64
|
||
import json
|
||
import logging
|
||
import os
|
||
import secrets
|
||
import time
|
||
from dataclasses import dataclass
|
||
from datetime import datetime, timedelta
|
||
from typing import Any, Dict, Optional, Tuple, Callable
|
||
|
||
from app.core._blockchain.ton.nft_license_manager import NFTLicenseManager
|
||
from app.core.crypto.content_cipher import ContentCipher
|
||
|
||
logger = logging.getLogger(__name__)
|
||
|
||
|
||
@dataclass(frozen=True)
|
||
class StreamingToken:
|
||
token: str
|
||
content_id: str
|
||
owner_address: str
|
||
issued_at: float
|
||
expires_at: float
|
||
|
||
def is_valid(self, now: Optional[float] = None) -> bool:
|
||
now = now or time.time()
|
||
return now < self.expires_at
|
||
|
||
|
||
class ContentAccessManager:
|
||
"""
|
||
Управление доступом к зашифрованному контенту по NFT лицензиям в TON.
|
||
|
||
Обязанности:
|
||
- grant_access(): принять tonProof + content_id, проверить лицензию, выдать временный токен
|
||
- verify_access(): валидация токена при запросе стрима/скачивания
|
||
- create_streaming_token(): генерация подписанного/непредсказуемого токена с TTL
|
||
- stream/decrypt: интеграция с ContentCipher — расшифровка возможна только при валидной лицензии/токене
|
||
"""
|
||
|
||
DEFAULT_TOKEN_TTL_SEC = int(os.getenv("STREAM_TOKEN_TTL_SEC", "600")) # 10 минут по умолчанию
|
||
|
||
def __init__(
|
||
self,
|
||
nft_manager: Optional[NFTLicenseManager] = None,
|
||
cipher: Optional[ContentCipher] = None,
|
||
):
|
||
self.nft_manager = nft_manager or NFTLicenseManager()
|
||
self.cipher = cipher or ContentCipher()
|
||
# Простой in-memory storage токенов. Для продакшена стоит заменить на Redis или БД.
|
||
self._tokens: Dict[str, StreamingToken] = {}
|
||
logger.debug("ContentAccessManager initialized; token_ttl=%s", self.DEFAULT_TOKEN_TTL_SEC)
|
||
|
||
def create_streaming_token(self, content_id: str, owner_address: str, ttl_sec: Optional[int] = None) -> StreamingToken:
|
||
ttl = ttl_sec or self.DEFAULT_TOKEN_TTL_SEC
|
||
token = base64.urlsafe_b64encode(secrets.token_bytes(32)).decode("ascii").rstrip("=")
|
||
now = time.time()
|
||
st = StreamingToken(
|
||
token=token,
|
||
content_id=content_id,
|
||
owner_address=owner_address,
|
||
issued_at=now,
|
||
expires_at=now + ttl,
|
||
)
|
||
self._tokens[token] = st
|
||
logger.info("Streaming token issued content_id=%s owner=%s ttl=%s", content_id, owner_address, ttl)
|
||
return st
|
||
|
||
def verify_access(self, token: str, content_id: str) -> Tuple[bool, Optional[str], Optional[StreamingToken]]:
|
||
if not token:
|
||
return False, "Missing token", None
|
||
st = self._tokens.get(token)
|
||
if not st:
|
||
return False, "Token not found", None
|
||
if not st.is_valid():
|
||
# Удаляем просроченный
|
||
self._tokens.pop(token, None)
|
||
return False, "Token expired", None
|
||
if st.content_id != content_id:
|
||
return False, "Token/content mismatch", None
|
||
logger.debug("Streaming token verified for content_id=%s owner=%s", st.content_id, st.owner_address)
|
||
return True, None, st
|
||
|
||
async def grant_access(
|
||
self,
|
||
ton_proof: Dict[str, Any],
|
||
content_id: str,
|
||
nft_address: Optional[str] = None,
|
||
token_ttl_sec: Optional[int] = None,
|
||
) -> Tuple[bool, Optional[str], Optional[Dict[str, Any]]]:
|
||
"""
|
||
Композитный сценарий: валидируем tonProof, проверяем владение NFT лицензией,
|
||
создаем временный токен для стриминга.
|
||
Возвращает: (ok, error, payload)
|
||
payload: { token, expires_at, owner_address, nft_item }
|
||
"""
|
||
try:
|
||
ok, err, nft_item = await self.nft_manager.check_license_validity(
|
||
ton_proof=ton_proof,
|
||
content_id=content_id,
|
||
nft_address=nft_address,
|
||
)
|
||
if not ok:
|
||
return False, err, None
|
||
|
||
owner_address = nft_proof_owner(ton_proof)
|
||
token = self.create_streaming_token(content_id, owner_address, token_ttl_sec)
|
||
payload = {
|
||
"token": token.token,
|
||
"expires_at": token.expires_at,
|
||
"owner_address": token.owner_address,
|
||
"nft_item": nft_item,
|
||
}
|
||
return True, None, payload
|
||
except Exception as e:
|
||
logger.exception("grant_access failed")
|
||
return False, str(e), None
|
||
|
||
def decrypt_for_stream(
|
||
self,
|
||
encrypted_obj: Dict[str, Any],
|
||
content_key_provider: Callable[[str], bytes],
|
||
token: str,
|
||
content_id: str,
|
||
associated_data: Optional[bytes] = None,
|
||
) -> Tuple[bool, Optional[str], Optional[bytes]]:
|
||
"""
|
||
Расшифровка данных для стрима. Требует валидного стрим-токена.
|
||
content_key_provider(content_id) -> bytes (32)
|
||
"""
|
||
ok, err, st = self.verify_access(token, content_id)
|
||
if not ok:
|
||
return False, err, None
|
||
|
||
try:
|
||
# В идеале проверяем целостность до расшифровки
|
||
# Здесь можем опционально вызвать verify_content_integrity, если есть сигнатуры
|
||
# Но основной критерий — валидный токен.
|
||
key = content_key_provider(content_id)
|
||
pt = self.cipher.decrypt_content(
|
||
ciphertext_b64=encrypted_obj["ciphertext_b64"],
|
||
nonce_b64=encrypted_obj["nonce_b64"],
|
||
tag_b64=encrypted_obj["tag_b64"],
|
||
key=key,
|
||
associated_data=associated_data,
|
||
)
|
||
logger.info("Decryption for stream succeeded content_id=%s owner=%s", content_id, st.owner_address)
|
||
return True, None, pt
|
||
except Exception as e:
|
||
logger.exception("decrypt_for_stream failed")
|
||
return False, str(e), None
|
||
|
||
|
||
def nft_proof_owner(ton_proof: Dict[str, Any]) -> str:
|
||
"""
|
||
Извлечь адрес владельца из структуры tonProof запроса клиента.
|
||
Совместимо с TonConnect unpack_wallet_info формой.
|
||
"""
|
||
# Поддержка как плоской формы, так и вложенной ton_proof
|
||
if "address" in ton_proof:
|
||
return ton_proof["address"]
|
||
if "account" in ton_proof and ton_proof["account"] and "address" in ton_proof["account"]:
|
||
return ton_proof["account"]["address"]
|
||
if "ton_proof" in ton_proof and ton_proof["ton_proof"] and "address" in ton_proof["ton_proof"]:
|
||
return ton_proof["ton_proof"]["address"]
|
||
# В противном случае бросаем: пусть вызывающий слой отловит
|
||
raise ValueError("Cannot extract owner address from ton_proof") |