mirror of
https://github.com/lrnselfreliance/wrolpi.git
synced 2026-10-26 19:01:39 -04:00
393 lines
11 KiB
Python
393 lines
11 KiB
Python
"""
|
|
Encrypted cookies management for yt-dlp video downloads.
|
|
|
|
This module provides secure storage and retrieval of browser cookies for use with yt-dlp.
|
|
Cookies are encrypted using Fernet (AES-128-CBC + HMAC-SHA256) with PBKDF2 key derivation.
|
|
|
|
Security features:
|
|
- Encryption: Fernet (AES-128-CBC + HMAC-SHA256)
|
|
- Key derivation: PBKDF2-SHA256 with 480k iterations (OWASP 2024 minimum)
|
|
- Password handling: Decrypt once to memory, forget password immediately
|
|
- In-memory storage: Decrypted cookies in shared memory across workers
|
|
- Per-download temp files: Unique file per download, deleted immediately after
|
|
- Temp file permissions: chmod 600 (owner only)
|
|
- Secure deletion: Overwrite temp file with zeros before unlinking
|
|
"""
|
|
import base64
|
|
import os
|
|
import pathlib
|
|
import secrets
|
|
import tempfile
|
|
from contextlib import contextmanager
|
|
from typing import Optional, Tuple
|
|
|
|
from cryptography.fernet import Fernet, InvalidToken
|
|
from cryptography.hazmat.primitives import hashes
|
|
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
|
|
|
|
from wrolpi import flags
|
|
from wrolpi.common import get_media_directory, logger
|
|
|
|
logger = logger.getChild(__name__)
|
|
|
|
# OWASP 2024 minimum iterations for PBKDF2-SHA256
|
|
PBKDF2_ITERATIONS = 480_000
|
|
SALT_SIZE = 16
|
|
MIN_PASSWORD_LENGTH = 8
|
|
|
|
# Encrypted cookies file path relative to config directory
|
|
COOKIES_FILENAME = 'cookies.txt.enc'
|
|
|
|
def _get_shared_cookies() -> Optional[bytes]:
|
|
"""Get cookies from shared context."""
|
|
from wrolpi.root_api import api_app
|
|
return api_app.shared_ctx.secure_cookies.get('content')
|
|
|
|
|
|
def _set_shared_cookies(content: bytes) -> None:
|
|
"""Store cookies in shared context for cross-worker access."""
|
|
from wrolpi.root_api import api_app
|
|
with api_app.shared_ctx.secure_cookies_lock:
|
|
api_app.shared_ctx.secure_cookies['content'] = content
|
|
|
|
|
|
def _clear_shared_cookies() -> None:
|
|
"""Clear cookies from shared context."""
|
|
from wrolpi.root_api import api_app
|
|
with api_app.shared_ctx.secure_cookies_lock:
|
|
api_app.shared_ctx.secure_cookies.clear()
|
|
|
|
|
|
def get_cookies_file_path() -> pathlib.Path:
|
|
"""Get the path to the encrypted cookies file."""
|
|
config_dir = get_media_directory() / 'config'
|
|
config_dir.mkdir(parents=True, exist_ok=True)
|
|
return config_dir / COOKIES_FILENAME
|
|
|
|
|
|
def _derive_key(password: str, salt: bytes) -> bytes:
|
|
"""Derive a Fernet key from password and salt using PBKDF2."""
|
|
kdf = PBKDF2HMAC(
|
|
algorithm=hashes.SHA256(),
|
|
length=32,
|
|
salt=salt,
|
|
iterations=PBKDF2_ITERATIONS,
|
|
)
|
|
key = base64.urlsafe_b64encode(kdf.derive(password.encode('utf-8')))
|
|
return key
|
|
|
|
|
|
def encrypt_cookies(content: str, password: str) -> bytes:
|
|
"""
|
|
Encrypt cookies content with password.
|
|
|
|
Args:
|
|
content: The plain text cookies content (Netscape format)
|
|
password: User-provided password (min 8 chars)
|
|
|
|
Returns:
|
|
Encrypted bytes: [16 bytes salt][Fernet encrypted content]
|
|
|
|
Raises:
|
|
ValueError: If password is too short
|
|
"""
|
|
if len(password) < MIN_PASSWORD_LENGTH:
|
|
raise ValueError(f'Password must be at least {MIN_PASSWORD_LENGTH} characters')
|
|
|
|
# Generate random salt
|
|
salt = secrets.token_bytes(SALT_SIZE)
|
|
|
|
# Derive key from password
|
|
key = _derive_key(password, salt)
|
|
|
|
# Encrypt content
|
|
fernet = Fernet(key)
|
|
encrypted = fernet.encrypt(content.encode('utf-8'))
|
|
|
|
# Prepend salt to encrypted content
|
|
return salt + encrypted
|
|
|
|
|
|
def decrypt_cookies(encrypted: bytes, password: str) -> str:
|
|
"""
|
|
Decrypt cookies content with password.
|
|
|
|
Args:
|
|
encrypted: Encrypted bytes from encrypt_cookies()
|
|
password: User-provided password
|
|
|
|
Returns:
|
|
Decrypted cookies content string
|
|
|
|
Raises:
|
|
ValueError: If data is corrupted or password is wrong
|
|
"""
|
|
if len(encrypted) < SALT_SIZE:
|
|
raise ValueError('Encrypted data is too short')
|
|
|
|
# Extract salt and encrypted content
|
|
salt = encrypted[:SALT_SIZE]
|
|
ciphertext = encrypted[SALT_SIZE:]
|
|
|
|
# Derive key from password
|
|
key = _derive_key(password, salt)
|
|
|
|
# Decrypt content
|
|
fernet = Fernet(key)
|
|
try:
|
|
decrypted = fernet.decrypt(ciphertext)
|
|
return decrypted.decode('utf-8')
|
|
except InvalidToken:
|
|
raise ValueError('Invalid password or corrupted data')
|
|
|
|
|
|
def validate_cookies_format(content: str) -> Tuple[bool, Optional[str]]:
|
|
"""
|
|
Validate that the content appears to be valid Netscape cookies format.
|
|
|
|
Args:
|
|
content: The cookies content to validate
|
|
|
|
Returns:
|
|
Tuple of (is_valid, error_message)
|
|
"""
|
|
if not content or not content.strip():
|
|
return False, 'Cookies content is empty'
|
|
|
|
lines = content.strip().split('\n')
|
|
valid_cookie_lines = 0
|
|
|
|
for line in lines:
|
|
line = line.strip()
|
|
|
|
# Skip empty lines
|
|
if not line:
|
|
continue
|
|
|
|
# Skip comments (lines starting with #)
|
|
if line.startswith('#'):
|
|
continue
|
|
|
|
# Each cookie line should have at least 7 tab-separated fields:
|
|
# domain, flag, path, secure, expiration, name, value
|
|
fields = line.split('\t')
|
|
if len(fields) >= 7:
|
|
valid_cookie_lines += 1
|
|
else:
|
|
# Allow some flexibility - some formats use fewer fields
|
|
# But should have at least domain and name
|
|
if len(fields) < 2:
|
|
return False, f'Invalid cookie line format: {line[:50]}...'
|
|
|
|
if valid_cookie_lines == 0:
|
|
return False, 'No valid cookie lines found. Expected Netscape/Mozilla cookies.txt format.'
|
|
|
|
return True, None
|
|
|
|
|
|
def save_encrypted_cookies(content: str, password: str) -> pathlib.Path:
|
|
"""
|
|
Validate, encrypt, and save cookies to the config directory.
|
|
|
|
Auto-unlocks cookies after saving (user just provided password).
|
|
|
|
Args:
|
|
content: Plain text cookies content
|
|
password: User-provided password
|
|
|
|
Returns:
|
|
Path to the saved encrypted file
|
|
|
|
Raises:
|
|
ValueError: If cookies format is invalid or password is too short
|
|
"""
|
|
# Validate format
|
|
is_valid, error = validate_cookies_format(content)
|
|
if not is_valid:
|
|
raise ValueError(error)
|
|
|
|
# Encrypt
|
|
encrypted = encrypt_cookies(content, password)
|
|
|
|
# Save to file
|
|
cookies_path = get_cookies_file_path()
|
|
cookies_path.write_bytes(encrypted)
|
|
|
|
# Set restrictive permissions (owner only)
|
|
os.chmod(cookies_path, 0o600)
|
|
|
|
# Set flags and auto-unlock (user just provided the password)
|
|
flags.cookies_exist.set()
|
|
_set_shared_cookies(content.encode('utf-8'))
|
|
flags.cookies_unlocked.set()
|
|
|
|
logger.info(f'Saved encrypted cookies to {cookies_path}')
|
|
return cookies_path
|
|
|
|
|
|
def cookies_exist() -> bool:
|
|
"""Check if encrypted cookies file exists."""
|
|
return get_cookies_file_path().is_file()
|
|
|
|
|
|
def delete_cookies() -> bool:
|
|
"""
|
|
Securely delete the encrypted cookies file.
|
|
|
|
Returns:
|
|
True if file was deleted, False if it didn't exist
|
|
"""
|
|
cookies_path = get_cookies_file_path()
|
|
if not cookies_path.is_file():
|
|
return False
|
|
|
|
# Overwrite with zeros before deletion
|
|
size = cookies_path.stat().st_size
|
|
cookies_path.write_bytes(b'\x00' * size)
|
|
cookies_path.unlink()
|
|
|
|
# Clear flags
|
|
flags.cookies_exist.clear()
|
|
flags.cookies_unlocked.clear()
|
|
|
|
# Also clear session if unlocked
|
|
lock_cookies()
|
|
|
|
logger.info('Deleted encrypted cookies file')
|
|
return True
|
|
|
|
|
|
def unlock_cookies(password: str) -> None:
|
|
"""
|
|
Decrypt cookies to memory for session use.
|
|
|
|
The password is NOT stored - it goes out of scope after this function returns.
|
|
|
|
Args:
|
|
password: User-provided password
|
|
|
|
Raises:
|
|
FileNotFoundError: If no encrypted cookies file exists
|
|
ValueError: If password is wrong or data is corrupted
|
|
"""
|
|
cookies_path = get_cookies_file_path()
|
|
if not cookies_path.is_file():
|
|
raise FileNotFoundError('No encrypted cookies file found')
|
|
|
|
encrypted = cookies_path.read_bytes()
|
|
decrypted = decrypt_cookies(encrypted, password)
|
|
|
|
# Store in shared context for cross-worker access
|
|
_set_shared_cookies(decrypted.encode('utf-8'))
|
|
flags.cookies_unlocked.set()
|
|
logger.info('Cookies unlocked to shared memory')
|
|
|
|
|
|
def lock_cookies() -> None:
|
|
"""Clear cookies from shared memory."""
|
|
_clear_shared_cookies()
|
|
flags.cookies_unlocked.clear()
|
|
logger.info('Cookies locked')
|
|
|
|
|
|
def cookies_unlocked() -> bool:
|
|
"""Check if cookies are decrypted in shared memory."""
|
|
shared_content = _get_shared_cookies()
|
|
return shared_content is not None and len(shared_content) > 0
|
|
|
|
|
|
def _get_secure_temp_dir() -> Optional[str]:
|
|
"""
|
|
Get the most secure temporary directory available.
|
|
|
|
Prefers /dev/shm (RAM-based) over /tmp (disk-based) to prevent
|
|
decrypted cookies from ever touching persistent storage.
|
|
|
|
Returns:
|
|
Path to /dev/shm if available and writable, None otherwise
|
|
(None causes tempfile to use system default).
|
|
"""
|
|
shm_path = pathlib.Path('/dev/shm')
|
|
if shm_path.is_dir() and os.access(shm_path, os.W_OK):
|
|
return str(shm_path)
|
|
# Fall back to system default (typically /tmp)
|
|
return None
|
|
|
|
|
|
@contextmanager
|
|
def cookies_for_download():
|
|
"""
|
|
Context manager providing a unique temp file for one download.
|
|
|
|
Writes shared cookies to a unique temp file, yields the path for yt-dlp,
|
|
then securely deletes the temp file.
|
|
|
|
Uses /dev/shm (RAM-based tmpfs) when available to prevent cookies from
|
|
ever touching persistent storage. Falls back to system temp directory.
|
|
|
|
Usage:
|
|
with cookies_for_download() as cookies_path:
|
|
cmd = (*cmd, '--cookies', str(cookies_path))
|
|
await run_download(cmd)
|
|
# Temp file is securely deleted here
|
|
|
|
Yields:
|
|
pathlib.Path: Path to temporary cookies file
|
|
|
|
Raises:
|
|
RuntimeError: If cookies are not unlocked
|
|
"""
|
|
if not cookies_unlocked():
|
|
raise RuntimeError('Cookies not unlocked')
|
|
|
|
# Get cookies from shared context
|
|
cookies_content = _get_shared_cookies()
|
|
|
|
# Create unique temp file, prefer /dev/shm (RAM) over /tmp (disk)
|
|
# Use opaque prefix to avoid revealing file purpose
|
|
fd, temp_path = tempfile.mkstemp(
|
|
suffix='.tmp',
|
|
prefix='w_',
|
|
dir=_get_secure_temp_dir()
|
|
)
|
|
temp_file = pathlib.Path(temp_path)
|
|
logger.trace(f'Created temp cookies file at {temp_path}')
|
|
|
|
try:
|
|
# Set restrictive permissions
|
|
os.chmod(temp_path, 0o600)
|
|
|
|
# Write cookies content
|
|
with os.fdopen(fd, 'w') as f:
|
|
f.write(cookies_content.decode('utf-8'))
|
|
|
|
yield temp_file
|
|
finally:
|
|
# Securely delete temp file
|
|
if temp_file.exists():
|
|
try:
|
|
size = temp_file.stat().st_size
|
|
temp_file.write_bytes(b'\x00' * size)
|
|
temp_file.unlink()
|
|
except Exception as e:
|
|
logger.warning(f'Failed to securely delete temp cookies file: {e}')
|
|
# Try to delete anyway
|
|
try:
|
|
temp_file.unlink()
|
|
except Exception:
|
|
pass
|
|
|
|
|
|
def get_cookies_status() -> dict:
|
|
"""
|
|
Get the current status of cookies.
|
|
|
|
Returns:
|
|
Dict with keys:
|
|
- cookies_exist: bool - Whether encrypted file exists
|
|
- cookies_unlocked: bool - Whether cookies are decrypted in memory
|
|
"""
|
|
return {
|
|
'cookies_exist': cookies_exist(),
|
|
'cookies_unlocked': cookies_unlocked(),
|
|
}
|