Files
media-downloader/modules/private_gallery_crypto.py
Todd 0d7b2b1aab Initial commit
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-29 22:42:55 -04:00

874 lines
29 KiB
Python

#!/usr/bin/env python3
"""
Private Gallery Encryption Module
Provides security features for the Private Gallery:
- Password hashing with bcrypt
- Key derivation with Argon2id
- File encryption/decryption with AES-256-GCM
- Field encryption with Fernet
- Session token management
"""
import os
import secrets
import hashlib
import base64
import time
from datetime import datetime, timedelta
from typing import Optional, Dict, Tuple
from pathlib import Path
from threading import Lock
try:
import bcrypt
except ImportError:
bcrypt = None
try:
from argon2 import PasswordHasher
from argon2.low_level import hash_secret_raw, Type
ARGON2_AVAILABLE = True
except ImportError:
ARGON2_AVAILABLE = False
try:
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives.ciphers.aead import AESGCM
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
CRYPTO_AVAILABLE = True
except ImportError:
CRYPTO_AVAILABLE = False
from modules.universal_logger import get_logger
logger = get_logger('PrivateGalleryCrypto')
class PrivateGalleryCrypto:
"""
Handles all encryption operations for the Private Gallery.
Security features:
- Passwords hashed with bcrypt (cost factor 12)
- Encryption key derived from password using Argon2id
- Files encrypted with AES-256-GCM
- Database fields encrypted with Fernet (AES-128-CBC + HMAC)
- Session tokens with configurable timeout
"""
# Argon2id parameters (OWASP recommended)
ARGON2_TIME_COST = 3
ARGON2_MEMORY_COST = 65536 # 64 MiB
ARGON2_PARALLELISM = 4
ARGON2_HASH_LENGTH = 32 # 256 bits for AES-256
# AES-GCM parameters
AES_KEY_SIZE = 32 # 256 bits
AES_NONCE_SIZE = 12 # 96 bits (GCM recommended)
AES_TAG_SIZE = 16 # 128 bits
# Encryption chunk size for streaming large files
CHUNK_SIZE = 8 * 1024 * 1024 # 8 MB chunks
CHUNKED_THRESHOLD = 50 * 1024 * 1024 # Use chunked encryption for files > 50 MB
CHUNKED_MAGIC = b'\x01PGCE' # Magic bytes: version 1, Private Gallery Chunked Encryption
def __init__(self):
self._sessions: Dict[str, Dict] = {} # token -> {expiry, username}
self._session_lock = Lock()
self._derived_key: Optional[bytes] = None
self._fernet: Optional[Fernet] = None
self._aesgcm: Optional[AESGCM] = None
# Check dependencies
if not bcrypt:
logger.warning("bcrypt not available - password hashing will use fallback")
if not ARGON2_AVAILABLE:
logger.warning("argon2-cffi not available - key derivation will use PBKDF2")
if not CRYPTO_AVAILABLE:
raise ImportError("cryptography library required for Private Gallery")
# =========================================================================
# PASSWORD HASHING (bcrypt)
# =========================================================================
def hash_password(self, password: str) -> str:
"""
Hash a password using bcrypt with cost factor 12.
Args:
password: Plain text password
Returns:
bcrypt hash string (includes salt)
"""
if bcrypt:
salt = bcrypt.gensalt(rounds=12)
hashed = bcrypt.hashpw(password.encode('utf-8'), salt)
return hashed.decode('utf-8')
else:
# Fallback to PBKDF2 if bcrypt not available
salt = secrets.token_bytes(16)
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=600000,
)
key = kdf.derive(password.encode('utf-8'))
return f"pbkdf2${base64.b64encode(salt).decode()}${base64.b64encode(key).decode()}"
def verify_password(self, password: str, password_hash: str) -> bool:
"""
Verify a password against its hash.
Args:
password: Plain text password to check
password_hash: Stored hash to verify against
Returns:
True if password matches
"""
try:
if password_hash.startswith('pbkdf2$'):
# PBKDF2 fallback hash
parts = password_hash.split('$')
if len(parts) != 3:
return False
salt = base64.b64decode(parts[1])
stored_key = base64.b64decode(parts[2])
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=salt,
iterations=600000,
)
try:
kdf.verify(password.encode('utf-8'), stored_key)
return True
except Exception:
return False
elif bcrypt:
return bcrypt.checkpw(
password.encode('utf-8'),
password_hash.encode('utf-8')
)
else:
return False
except Exception as e:
logger.error(f"Password verification failed: {e}")
return False
# =========================================================================
# KEY DERIVATION (Argon2id or PBKDF2)
# =========================================================================
def derive_key(self, password: str, salt: bytes) -> bytes:
"""
Derive an encryption key from password using Argon2id.
Args:
password: User's password
salt: Random salt (should be stored)
Returns:
32-byte derived key for AES-256
"""
if ARGON2_AVAILABLE:
key = hash_secret_raw(
secret=password.encode('utf-8'),
salt=salt,
time_cost=self.ARGON2_TIME_COST,
memory_cost=self.ARGON2_MEMORY_COST,
parallelism=self.ARGON2_PARALLELISM,
hash_len=self.ARGON2_HASH_LENGTH,
type=Type.ID # Argon2id
)
return key
else:
# Fallback to PBKDF2 with high iterations
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=self.AES_KEY_SIZE,
salt=salt,
iterations=600000, # OWASP recommended minimum
)
return kdf.derive(password.encode('utf-8'))
def generate_salt(self) -> bytes:
"""Generate a cryptographically secure random salt."""
return secrets.token_bytes(16)
def initialize_encryption(self, password: str, salt: bytes) -> None:
"""
Initialize encryption with derived key.
Must be called after successful unlock.
Args:
password: User's password
salt: Stored salt for key derivation
"""
self._derived_key = self.derive_key(password, salt)
# Initialize Fernet for field encryption
# Fernet requires a 32-byte key, base64-encoded
fernet_key = base64.urlsafe_b64encode(self._derived_key)
self._fernet = Fernet(fernet_key)
# Initialize AES-GCM for file encryption
self._aesgcm = AESGCM(self._derived_key)
logger.info("Encryption initialized successfully")
def clear_encryption(self) -> None:
"""Clear encryption keys from memory (on lock)."""
self._derived_key = None
self._fernet = None
self._aesgcm = None
logger.info("Encryption keys cleared")
def is_initialized(self) -> bool:
"""Check if encryption is initialized (unlocked)."""
return self._derived_key is not None
# =========================================================================
# FIELD ENCRYPTION (Fernet - for database fields)
# =========================================================================
def encrypt_field(self, plaintext: str) -> str:
"""
Encrypt a database field value.
Args:
plaintext: Plain text to encrypt
Returns:
Base64-encoded encrypted string
"""
if not self._fernet:
raise RuntimeError("Encryption not initialized - call initialize_encryption first")
if not plaintext:
return ""
encrypted = self._fernet.encrypt(plaintext.encode('utf-8'))
return base64.urlsafe_b64encode(encrypted).decode('utf-8')
def decrypt_field(self, ciphertext: str) -> str:
"""
Decrypt a database field value.
Args:
ciphertext: Base64-encoded encrypted string
Returns:
Decrypted plain text
"""
if not self._fernet:
raise RuntimeError("Encryption not initialized - call initialize_encryption first")
if not ciphertext:
return ""
try:
encrypted = base64.urlsafe_b64decode(ciphertext.encode('utf-8'))
decrypted = self._fernet.decrypt(encrypted)
return decrypted.decode('utf-8')
except Exception as e:
logger.error(f"Field decryption failed: {e}")
return "[Decryption Error]"
# =========================================================================
# FILE ENCRYPTION (AES-256-GCM)
# =========================================================================
def encrypt_file(self, input_path: Path, output_path: Path) -> bool:
"""
Encrypt a file using AES-256-GCM.
Small files (<=50MB): single-shot format
[12-byte nonce][encrypted data + 16-byte tag]
Large files (>50MB): chunked format for memory efficiency
[5-byte magic 0x01PGCE][4-byte chunk_size BE]
[12-byte nonce][encrypted chunk + 16-byte tag] (repeated)
Args:
input_path: Path to plaintext file
output_path: Path for encrypted output
Returns:
True if successful
"""
if not self._aesgcm:
raise RuntimeError("Encryption not initialized")
try:
file_size = input_path.stat().st_size
output_path.parent.mkdir(parents=True, exist_ok=True)
if file_size <= self.CHUNKED_THRESHOLD:
# Small file: single-shot encryption (backward compatible)
nonce = secrets.token_bytes(self.AES_NONCE_SIZE)
with open(input_path, 'rb') as f:
plaintext = f.read()
ciphertext = self._aesgcm.encrypt(nonce, plaintext, None)
with open(output_path, 'wb') as f:
f.write(nonce)
f.write(ciphertext)
else:
# Large file: chunked encryption
import struct
with open(input_path, 'rb') as fin, open(output_path, 'wb') as fout:
# Write header
fout.write(self.CHUNKED_MAGIC)
fout.write(struct.pack('>I', self.CHUNK_SIZE))
# Encrypt in chunks
while True:
chunk = fin.read(self.CHUNK_SIZE)
if not chunk:
break
nonce = secrets.token_bytes(self.AES_NONCE_SIZE)
encrypted_chunk = self._aesgcm.encrypt(nonce, chunk, None)
# Write chunk: nonce + encrypted data (includes GCM tag)
fout.write(nonce)
fout.write(struct.pack('>I', len(encrypted_chunk)))
fout.write(encrypted_chunk)
return True
except Exception as e:
logger.error(f"File encryption failed: {e}")
# Clean up partial output
if output_path.exists():
try:
output_path.unlink()
except Exception:
pass
return False
def _is_chunked_format(self, input_path: Path) -> bool:
"""Check if an encrypted file uses the chunked format."""
try:
with open(input_path, 'rb') as f:
magic = f.read(len(self.CHUNKED_MAGIC))
return magic == self.CHUNKED_MAGIC
except Exception:
return False
def decrypt_file(self, input_path: Path, output_path: Optional[Path] = None) -> Optional[bytes]:
"""
Decrypt a file encrypted with AES-256-GCM.
Handles both single-shot and chunked formats.
Args:
input_path: Path to encrypted file
output_path: Optional path to write decrypted file
Returns:
Decrypted bytes if output_path is None, else None on success
"""
if not self._aesgcm:
raise RuntimeError("Encryption not initialized")
try:
if self._is_chunked_format(input_path):
return self._decrypt_file_chunked(input_path, output_path)
# Single-shot format: [nonce][ciphertext+tag]
with open(input_path, 'rb') as f:
nonce = f.read(self.AES_NONCE_SIZE)
if len(nonce) != self.AES_NONCE_SIZE:
raise ValueError("Invalid encrypted file: missing nonce")
ciphertext = f.read()
plaintext = self._aesgcm.decrypt(nonce, ciphertext, None)
if output_path:
output_path.parent.mkdir(parents=True, exist_ok=True)
with open(output_path, 'wb') as f:
f.write(plaintext)
return None
return plaintext
except Exception as e:
logger.error(f"File decryption failed: {e}")
return None
def _decrypt_file_chunked(self, input_path: Path, output_path: Optional[Path] = None) -> Optional[bytes]:
"""Decrypt a chunked-format encrypted file."""
import struct
try:
parts = [] if output_path is None else None
with open(input_path, 'rb') as fin:
# Read header
magic = fin.read(len(self.CHUNKED_MAGIC))
if magic != self.CHUNKED_MAGIC:
raise ValueError("Invalid chunked file header")
chunk_size_bytes = fin.read(4)
# chunk_size from header (informational, actual sizes are per-chunk)
struct.unpack('>I', chunk_size_bytes)
fout = None
if output_path:
output_path.parent.mkdir(parents=True, exist_ok=True)
fout = open(output_path, 'wb')
try:
while True:
# Read chunk: [12-byte nonce][4-byte encrypted_len][encrypted data]
nonce = fin.read(self.AES_NONCE_SIZE)
if len(nonce) == 0:
break # EOF
if len(nonce) != self.AES_NONCE_SIZE:
raise ValueError("Truncated chunk nonce")
enc_len_bytes = fin.read(4)
if len(enc_len_bytes) != 4:
raise ValueError("Truncated chunk length")
enc_len = struct.unpack('>I', enc_len_bytes)[0]
encrypted_chunk = fin.read(enc_len)
if len(encrypted_chunk) != enc_len:
raise ValueError("Truncated chunk data")
decrypted_chunk = self._aesgcm.decrypt(nonce, encrypted_chunk, None)
if fout:
fout.write(decrypted_chunk)
else:
parts.append(decrypted_chunk)
finally:
if fout:
fout.close()
if output_path:
return None
return b''.join(parts)
except Exception as e:
logger.error(f"Chunked file decryption failed for {input_path}: {type(e).__name__}: {e}")
return None
def re_encrypt_to_chunked(self, file_path: Path) -> bool:
"""
Re-encrypt a single-shot encrypted file to chunked format in-place.
Decrypts and re-encrypts in chunks to avoid loading the entire file into memory.
Args:
file_path: Path to the single-shot encrypted file
Returns:
True if successful, False if already chunked or on error
"""
if not self._aesgcm:
raise RuntimeError("Encryption not initialized")
if self._is_chunked_format(file_path):
return False # Already chunked
import struct
temp_path = file_path.with_suffix(f'.enc.{secrets.token_hex(4)}.tmp')
try:
# Decrypt the single-shot file fully (required by AES-GCM)
with open(file_path, 'rb') as f:
nonce = f.read(self.AES_NONCE_SIZE)
if len(nonce) != self.AES_NONCE_SIZE:
raise ValueError("Invalid encrypted file")
ciphertext = f.read()
plaintext = self._aesgcm.decrypt(nonce, ciphertext, None)
del ciphertext # Free memory
# Write chunked format to temp file
with open(temp_path, 'wb') as fout:
fout.write(self.CHUNKED_MAGIC)
fout.write(struct.pack('>I', self.CHUNK_SIZE))
offset = 0
while offset < len(plaintext):
chunk = plaintext[offset:offset + self.CHUNK_SIZE]
offset += len(chunk)
chunk_nonce = secrets.token_bytes(self.AES_NONCE_SIZE)
encrypted_chunk = self._aesgcm.encrypt(chunk_nonce, chunk, None)
fout.write(chunk_nonce)
fout.write(struct.pack('>I', len(encrypted_chunk)))
fout.write(encrypted_chunk)
del plaintext # Free memory
# Atomic replace
temp_path.replace(file_path)
return True
except Exception as e:
logger.error(f"Re-encryption to chunked failed for {file_path}: {e}")
if temp_path.exists():
try:
temp_path.unlink()
except Exception:
pass
return False
def decrypt_file_streaming(self, input_path: Path) -> Optional[bytes]:
"""
Decrypt a file and return bytes for streaming.
Only suitable for small files (single-shot format, ≤50MB).
For large chunked files, use decrypt_file_generator() instead.
Args:
input_path: Path to encrypted file
Returns:
Decrypted bytes or None on error
"""
return self.decrypt_file(input_path, output_path=None)
def decrypt_file_generator(self, input_path: Path):
"""
Generator that yields decrypted chunks for streaming large files.
For chunked files, yields one decrypted chunk at a time (~8MB each).
For single-shot files, yields the entire content at once.
Args:
input_path: Path to encrypted file
Yields:
bytes: Decrypted data chunks
"""
import struct
if not self._aesgcm:
raise RuntimeError("Encryption not initialized")
if self._is_chunked_format(input_path):
with open(input_path, 'rb') as fin:
# Skip header
fin.read(len(self.CHUNKED_MAGIC))
fin.read(4)
while True:
nonce = fin.read(self.AES_NONCE_SIZE)
if len(nonce) == 0:
break
if len(nonce) != self.AES_NONCE_SIZE:
raise ValueError("Truncated chunk nonce")
enc_len_bytes = fin.read(4)
if len(enc_len_bytes) != 4:
raise ValueError("Truncated chunk length")
enc_len = struct.unpack('>I', enc_len_bytes)[0]
encrypted_chunk = fin.read(enc_len)
if len(encrypted_chunk) != enc_len:
raise ValueError("Truncated chunk data")
yield self._aesgcm.decrypt(nonce, encrypted_chunk, None)
else:
# Single-shot: yield everything at once (≤50MB)
with open(input_path, 'rb') as f:
nonce = f.read(self.AES_NONCE_SIZE)
if len(nonce) != self.AES_NONCE_SIZE:
raise ValueError("Invalid encrypted file: missing nonce")
ciphertext = f.read()
yield self._aesgcm.decrypt(nonce, ciphertext, None)
def decrypt_file_range_generator(self, input_path: Path, start: int, end: int):
"""
Generator that yields only the decrypted bytes for a specific byte range.
For chunked files, only decrypts the necessary chunks and slices them.
For single-shot files, decrypts all and slices.
Args:
input_path: Path to encrypted file
start: Start byte offset (inclusive)
end: End byte offset (inclusive)
Yields:
bytes: Decrypted data for the requested range
"""
import struct
if not self._aesgcm:
raise RuntimeError("Encryption not initialized")
if not self._is_chunked_format(input_path):
# Single-shot: decrypt all and slice (file is ≤50MB)
with open(input_path, 'rb') as f:
nonce = f.read(self.AES_NONCE_SIZE)
ciphertext = f.read()
plaintext = self._aesgcm.decrypt(nonce, ciphertext, None)
yield plaintext[start:end + 1]
return
chunk_size = self.CHUNK_SIZE
first_chunk = start // chunk_size
last_chunk = end // chunk_size
# Header: 5 magic + 4 chunk_size = 9 bytes
header_size = len(self.CHUNKED_MAGIC) + 4
# Each full encrypted chunk: 12 nonce + 4 length + (chunk_size + 16 tag)
enc_chunk_stride = self.AES_NONCE_SIZE + 4 + chunk_size + self.AES_TAG_SIZE
with open(input_path, 'rb') as fin:
for chunk_idx in range(first_chunk, last_chunk + 1):
# Seek to this chunk's position in the encrypted file
fin.seek(header_size + chunk_idx * enc_chunk_stride)
nonce = fin.read(self.AES_NONCE_SIZE)
if len(nonce) == 0:
break
if len(nonce) != self.AES_NONCE_SIZE:
raise ValueError("Truncated chunk nonce")
enc_len_bytes = fin.read(4)
if len(enc_len_bytes) != 4:
raise ValueError("Truncated chunk length")
enc_len = struct.unpack('>I', enc_len_bytes)[0]
encrypted_chunk = fin.read(enc_len)
if len(encrypted_chunk) != enc_len:
raise ValueError("Truncated chunk data")
decrypted_chunk = self._aesgcm.decrypt(nonce, encrypted_chunk, None)
# Calculate which part of this chunk we need
chunk_start_byte = chunk_idx * chunk_size
slice_start = max(start - chunk_start_byte, 0)
slice_end = min(end - chunk_start_byte + 1, len(decrypted_chunk))
yield decrypted_chunk[slice_start:slice_end]
# =========================================================================
# SESSION MANAGEMENT
# =========================================================================
def create_session(self, username: str = "user", timeout_minutes: int = 30) -> str:
"""
Create a new session token.
Args:
username: Username for the session
timeout_minutes: Session timeout in minutes
Returns:
Session token string
"""
token = secrets.token_urlsafe(32)
expiry = datetime.now() + timedelta(minutes=timeout_minutes)
with self._session_lock:
self._sessions[token] = {
'expiry': expiry,
'username': username,
'created_at': datetime.now()
}
logger.info(f"Created session for {username}, expires in {timeout_minutes} minutes")
return token
def verify_session(self, token: str) -> Optional[Dict]:
"""
Verify a session token is valid and not expired.
Args:
token: Session token to verify
Returns:
Session info dict if valid, None otherwise
"""
with self._session_lock:
session = self._sessions.get(token)
if not session:
return None
if datetime.now() > session['expiry']:
# Expired - remove it
del self._sessions[token]
return None
return session
def refresh_session(self, token: str, timeout_minutes: int = 30) -> bool:
"""
Refresh a session's expiry time.
Args:
token: Session token to refresh
timeout_minutes: New timeout in minutes
Returns:
True if refreshed, False if token invalid
"""
with self._session_lock:
session = self._sessions.get(token)
if not session:
return False
if datetime.now() > session['expiry']:
del self._sessions[token]
return False
session['expiry'] = datetime.now() + timedelta(minutes=timeout_minutes)
return True
def invalidate_session(self, token: str) -> bool:
"""
Invalidate a session token (logout/lock).
Args:
token: Session token to invalidate
Returns:
True if invalidated, False if not found
"""
with self._session_lock:
if token in self._sessions:
del self._sessions[token]
return True
return False
def invalidate_all_sessions(self) -> int:
"""
Invalidate all sessions (master lock).
Returns:
Number of sessions invalidated
"""
with self._session_lock:
count = len(self._sessions)
self._sessions.clear()
return count
def cleanup_expired_sessions(self) -> int:
"""
Remove all expired sessions.
Returns:
Number of sessions removed
"""
with self._session_lock:
now = datetime.now()
expired = [t for t, s in self._sessions.items() if now > s['expiry']]
for token in expired:
del self._sessions[token]
return len(expired)
def get_active_session_count(self) -> int:
"""Get count of active (non-expired) sessions."""
self.cleanup_expired_sessions()
return len(self._sessions)
# Global instance
_crypto_instance: Optional[PrivateGalleryCrypto] = None
_crypto_lock = Lock()
def get_private_gallery_crypto() -> PrivateGalleryCrypto:
"""Get or create the global crypto instance."""
global _crypto_instance
with _crypto_lock:
if _crypto_instance is None:
_crypto_instance = PrivateGalleryCrypto()
return _crypto_instance
def export_key_to_file(path: str) -> bool:
"""
Save the current derived key from the global crypto instance to a file.
The file is written with mode 0600 for security.
Args:
path: File path to write the key material to
Returns:
True if successful
"""
import json as _json
crypto = get_private_gallery_crypto()
if not crypto.is_initialized() or crypto._derived_key is None:
logger.warning("Cannot export key: encryption not initialized")
return False
try:
key_data = {
'derived_key': base64.b64encode(crypto._derived_key).decode('utf-8')
}
key_path = Path(path)
key_path.parent.mkdir(parents=True, exist_ok=True)
# Write atomically via temp file
tmp_path = key_path.with_suffix('.tmp')
with open(tmp_path, 'w') as f:
_json.dump(key_data, f)
os.chmod(str(tmp_path), 0o600)
tmp_path.replace(key_path)
logger.info(f"Exported encryption key to {path}")
return True
except Exception as e:
logger.error(f"Failed to export key to {path}: {e}")
return False
def load_key_from_file(path: str) -> Optional[PrivateGalleryCrypto]:
"""
Load a derived key from a file and return an initialized crypto instance.
Args:
path: File path containing the key material
Returns:
Initialized PrivateGalleryCrypto instance, or None if unavailable
"""
import json as _json
key_path = Path(path)
if not key_path.exists():
return None
try:
with open(key_path, 'r') as f:
key_data = _json.load(f)
derived_key = base64.b64decode(key_data['derived_key'])
crypto = PrivateGalleryCrypto()
crypto._derived_key = derived_key
# Initialize Fernet for field encryption
fernet_key = base64.urlsafe_b64encode(derived_key)
crypto._fernet = Fernet(fernet_key)
# Initialize AES-GCM for file encryption
crypto._aesgcm = AESGCM(derived_key)
return crypto
except Exception as e:
logger.error(f"Failed to load key from {path}: {e}")
return None
def delete_key_file(path: str) -> bool:
"""Delete the key file if it exists."""
try:
key_path = Path(path)
if key_path.exists():
key_path.unlink()
logger.info(f"Deleted key file {path}")
return True
except Exception as e:
logger.error(f"Failed to delete key file {path}: {e}")
return False