#!/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