From a2c4c99fccc9949544ee0f650d0a6e32a2054acf Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Sun, 28 Dec 2025 20:55:58 -0500 Subject: [PATCH] Clened up old redundant files. --- .gitignore | 3 + frontends/web/app.py_20251228 | 709 ------------------ frontends/web/static/.logo.svg.kate-swp | Bin 32268 -> 0 bytes frontends/web/static/style.css_20251228 | 409 ---------- .../web/templates/encode_result.html_20251228 | 95 --- frontends/web/templates/index.html_20251228 | 108 --- src_20251228/__init__.py | 0 src_20251228/main.py | 11 - src_20251228/stegasoo/__init__.py | 577 -------------- src_20251228/stegasoo/cli.py | 66 -- src_20251228/stegasoo/constants.py | 131 ---- src_20251228/stegasoo/crypto.py | 512 ------------- src_20251228/stegasoo/exceptions.py | 150 ---- src_20251228/stegasoo/keygen.py | 228 ------ src_20251228/stegasoo/models.py | 177 ----- src_20251228/stegasoo/qr_utils.py | 397 ---------- src_20251228/stegasoo/steganography.py | 350 --------- src_20251228/stegasoo/utils.py | 209 ------ src_20251228/stegasoo/validation.py | 426 ----------- 19 files changed, 3 insertions(+), 4555 deletions(-) delete mode 100644 frontends/web/app.py_20251228 delete mode 100644 frontends/web/static/.logo.svg.kate-swp delete mode 100644 frontends/web/static/style.css_20251228 delete mode 100644 frontends/web/templates/encode_result.html_20251228 delete mode 100644 frontends/web/templates/index.html_20251228 delete mode 100644 src_20251228/__init__.py delete mode 100644 src_20251228/main.py delete mode 100644 src_20251228/stegasoo/__init__.py delete mode 100644 src_20251228/stegasoo/cli.py delete mode 100644 src_20251228/stegasoo/constants.py delete mode 100644 src_20251228/stegasoo/crypto.py delete mode 100644 src_20251228/stegasoo/exceptions.py delete mode 100644 src_20251228/stegasoo/keygen.py delete mode 100644 src_20251228/stegasoo/models.py delete mode 100644 src_20251228/stegasoo/qr_utils.py delete mode 100644 src_20251228/stegasoo/steganography.py delete mode 100644 src_20251228/stegasoo/utils.py delete mode 100644 src_20251228/stegasoo/validation.py diff --git a/.gitignore b/.gitignore index a12c6d4..1b17709 100644 --- a/.gitignore +++ b/.gitignore @@ -26,6 +26,9 @@ venv/ ENV/ env/ +# Old versions of code files. +old_files/ + # IDE .idea/ .vscode/ diff --git a/frontends/web/app.py_20251228 b/frontends/web/app.py_20251228 deleted file mode 100644 index 1bad8da..0000000 --- a/frontends/web/app.py_20251228 +++ /dev/null @@ -1,709 +0,0 @@ -#!/usr/bin/env python3 -""" -Stegasoo Web Frontend - -Flask-based web UI for steganography operations. -Supports both text messages and file embedding. -""" - -import io -import sys -import time -import secrets -import mimetypes -from pathlib import Path -from datetime import datetime - -from flask import ( - Flask, render_template, request, send_file, - jsonify, flash, redirect, url_for -) - -# Add parent to path for development -sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src')) - -import stegasoo -from stegasoo import ( - encode, decode, generate_credentials, - export_rsa_key_pem, load_rsa_key, - validate_pin, validate_message, validate_image, - validate_rsa_key, validate_security_factors, - validate_file_payload, - get_today_day, generate_filename, - DAY_NAMES, __version__, - StegasooError, DecryptionError, CapacityError, - has_argon2, - FilePayload, - MAX_FILE_PAYLOAD_SIZE, -) -from stegasoo.constants import ( - MAX_MESSAGE_SIZE, MIN_PIN_LENGTH, MAX_PIN_LENGTH, - VALID_RSA_SIZES, MAX_FILE_SIZE, -) - -# QR Code support -try: - import qrcode - from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_M - HAS_QRCODE = True -except ImportError: - HAS_QRCODE = False - -# QR Code reading -try: - from pyzbar.pyzbar import decode as pyzbar_decode - HAS_QRCODE_READ = True -except ImportError: - HAS_QRCODE_READ = False - -import zlib -import base64 - -# Import QR utilities -from stegasoo.qr_utils import ( - compress_data, decompress_data, auto_decompress, - is_compressed, can_fit_in_qr, needs_compression, - generate_qr_code, read_qr_code, extract_key_from_qr, - has_qr_write, has_qr_read, - QR_MAX_BINARY, COMPRESSION_PREFIX -) - - -# ============================================================================ -# FLASK APP CONFIGURATION -# ============================================================================ - -app = Flask(__name__) -app.secret_key = secrets.token_hex(32) -app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE # 10MB max upload - -# Temporary file storage for sharing (file_id -> {data, timestamp, filename}) -TEMP_FILES: dict[str, dict] = {} -TEMP_FILE_EXPIRY = 300 # 5 minutes - - -def cleanup_temp_files(): - """Remove expired temporary files.""" - now = time.time() - expired = [fid for fid, info in TEMP_FILES.items() if now - info['timestamp'] > TEMP_FILE_EXPIRY] - for fid in expired: - TEMP_FILES.pop(fid, None) - - -def allowed_image(filename: str) -> bool: - """Check if file has allowed image extension.""" - if not filename or '.' not in filename: - return False - ext = filename.rsplit('.', 1)[1].lower() - return ext in {'png', 'jpg', 'jpeg', 'bmp', 'gif'} - - -def format_size(size_bytes: int) -> str: - """Format file size for display.""" - if size_bytes < 1024: - return f"{size_bytes} B" - elif size_bytes < 1024 * 1024: - return f"{size_bytes / 1024:.1f} KB" - else: - return f"{size_bytes / (1024 * 1024):.1f} MB" - - - -# ============================================================================ -# ROUTES -# ============================================================================ - -@app.route('/') -def index(): - return render_template('index.html') - - -@app.route('/generate', methods=['GET', 'POST']) -def generate(): - if request.method == 'POST': - words_per_phrase = int(request.form.get('words_per_phrase', 3)) - use_pin = request.form.get('use_pin') == 'on' - use_rsa = request.form.get('use_rsa') == 'on' - - if not use_pin and not use_rsa: - flash('You must select at least one security factor (PIN or RSA Key)', 'error') - return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) - - pin_length = int(request.form.get('pin_length', 6)) - rsa_bits = int(request.form.get('rsa_bits', 2048)) - - # Clamp values - words_per_phrase = max(3, min(12, words_per_phrase)) - pin_length = max(MIN_PIN_LENGTH, min(MAX_PIN_LENGTH, pin_length)) - if rsa_bits not in VALID_RSA_SIZES: - rsa_bits = 2048 - - try: - creds = generate_credentials( - use_pin=use_pin, - use_rsa=use_rsa, - pin_length=pin_length, - rsa_bits=rsa_bits, - words_per_phrase=words_per_phrase - ) - - # Store RSA key temporarily for QR generation - qr_token = None - qr_needs_compression = False - qr_too_large = False - - if creds.rsa_key_pem and HAS_QRCODE: - # Check if key fits in QR code - if can_fit_in_qr(creds.rsa_key_pem, compress=False): - qr_needs_compression = False - elif can_fit_in_qr(creds.rsa_key_pem, compress=True): - qr_needs_compression = True - else: - qr_too_large = True - - if not qr_too_large: - qr_token = secrets.token_urlsafe(16) - cleanup_temp_files() - TEMP_FILES[qr_token] = { - 'data': creds.rsa_key_pem.encode(), - 'filename': 'rsa_key.pem', - 'timestamp': time.time(), - 'type': 'rsa_key', - 'compress': qr_needs_compression - } - - return render_template('generate.html', - phrases=creds.phrases, - pin=creds.pin, - days=DAY_NAMES, - generated=True, - words_per_phrase=words_per_phrase, - pin_length=pin_length if use_pin else None, - use_pin=use_pin, - use_rsa=use_rsa, - rsa_bits=rsa_bits, - rsa_key_pem=creds.rsa_key_pem, - phrase_entropy=creds.phrase_entropy, - pin_entropy=creds.pin_entropy, - rsa_entropy=creds.rsa_entropy, - total_entropy=creds.total_entropy, - has_qrcode=HAS_QRCODE, - qr_token=qr_token, - qr_needs_compression=qr_needs_compression, - qr_too_large=qr_too_large - ) - except Exception as e: - flash(f'Error generating credentials: {e}', 'error') - return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) - - return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) - - -@app.route('/generate/qr/') -def generate_qr(token): - """Generate QR code for RSA key.""" - if not HAS_QRCODE: - return "QR code support not available", 501 - - if token not in TEMP_FILES: - return "Token expired or invalid", 404 - - file_info = TEMP_FILES[token] - if file_info.get('type') != 'rsa_key': - return "Invalid token type", 400 - - try: - key_pem = file_info['data'].decode('utf-8') - compress = file_info.get('compress', False) - qr_png = generate_qr_code(key_pem, compress=compress) - - return send_file( - io.BytesIO(qr_png), - mimetype='image/png', - as_attachment=False - ) - except Exception as e: - return f"Error generating QR code: {e}", 500 - - -@app.route('/generate/qr-download/') -def generate_qr_download(token): - """Download QR code as PNG file.""" - if not HAS_QRCODE: - return "QR code support not available", 501 - - if token not in TEMP_FILES: - return "Token expired or invalid", 404 - - file_info = TEMP_FILES[token] - if file_info.get('type') != 'rsa_key': - return "Invalid token type", 400 - - try: - key_pem = file_info['data'].decode('utf-8') - compress = file_info.get('compress', False) - qr_png = generate_qr_code(key_pem, compress=compress) - - return send_file( - io.BytesIO(qr_png), - mimetype='image/png', - as_attachment=True, - download_name='stegasoo_rsa_key_qr.png' - ) - except Exception as e: - return f"Error generating QR code: {e}", 500 - - -#@app.route('/generate/download-key', methods=['POST']) -#def download_key(): -# """Download RSA key as password-protected PEM file.""" -# key_pem = request.form.get('key_pem', '') - -@app.route('/generate/download-key', methods=['POST']) -def download_key(): - """Download RSA key as password-protected PEM file.""" - key_pem = request.form.get('key_pem', '') - password = request.form.get('key_password', '') - - if not key_pem: - flash('No key to download', 'error') - return redirect(url_for('generate')) - - if not password or len(password) < 8: - flash('Password must be at least 8 characters', 'error') - return redirect(url_for('generate')) - - try: - private_key = load_rsa_key(key_pem.encode('utf-8')) - encrypted_pem = export_rsa_key_pem(private_key, password=password) - - key_id = secrets.token_hex(4) - filename = f'stegasoo_key_{private_key.key_size}_{key_id}.pem' - - return send_file( - io.BytesIO(encrypted_pem), - mimetype='application/x-pem-file', - as_attachment=True, - download_name=filename - ) - except Exception as e: - flash(f'Error creating key file: {e}', 'error') - return redirect(url_for('generate')) - - -@app.route('/extract-key-from-qr', methods=['POST']) -def extract_key_from_qr_route(): - """ - Extract RSA key from uploaded QR code image. - Returns JSON with the extracted key or error. - """ - if not HAS_QRCODE_READ: - return jsonify({ - 'success': False, - 'error': 'QR code reading not available. Install pyzbar and libzbar.' - }), 501 - - qr_image = request.files.get('qr_image') - if not qr_image: - return jsonify({ - 'success': False, - 'error': 'No QR image provided' - }), 400 - - try: - image_data = qr_image.read() - key_pem = extract_key_from_qr(image_data) - - if key_pem: - return jsonify({ - 'success': True, - 'key_pem': key_pem - }) - else: - return jsonify({ - 'success': False, - 'error': 'No valid RSA key found in QR code' - }), 400 - - except Exception as e: - return jsonify({ - 'success': False, - 'error': str(e) - }), 500 - password = request.form.get('key_password', '') - - if not key_pem: - flash('No key to download', 'error') - return redirect(url_for('generate')) - - if not password or len(password) < 8: - flash('Password must be at least 8 characters', 'error') - return redirect(url_for('generate')) - - try: - private_key = load_rsa_key(key_pem.encode()) - encrypted_pem = export_rsa_key_pem(private_key, password=password) - - key_id = secrets.token_hex(4) - filename = f'stegasoo_key_{private_key.key_size}_{key_id}.pem' - - return send_file( - io.BytesIO(encrypted_pem), - mimetype='application/x-pem-file', - as_attachment=True, - download_name=filename - ) - except Exception as e: - flash(f'Error creating key file: {e}', 'error') - return redirect(url_for('generate')) - - -@app.route('/encode', methods=['GET', 'POST']) -def encode_page(): - day_of_week = get_today_day() - max_payload_kb = MAX_FILE_PAYLOAD_SIZE // 1024 - - if request.method == 'POST': - try: - # Get files - ref_photo = request.files.get('reference_photo') - carrier = request.files.get('carrier') - rsa_key_file = request.files.get('rsa_key') - payload_file = request.files.get('payload_file') - - if not ref_photo or not carrier: - flash('Both reference photo and carrier image are required', 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - if not allowed_image(ref_photo.filename) or not allowed_image(carrier.filename): - flash('Invalid file type. Use PNG, JPG, or BMP', 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Get form data - message = request.form.get('message', '') - day_phrase = request.form.get('day_phrase', '') - pin = request.form.get('pin', '').strip() - rsa_password = request.form.get('rsa_password', '') - payload_type = request.form.get('payload_type', 'text') - - # Determine payload - if payload_type == 'file' and payload_file and payload_file.filename: - # File payload - file_data = payload_file.read() - - result = validate_file_payload(file_data, payload_file.filename) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - mime_type, _ = mimetypes.guess_type(payload_file.filename) - payload = FilePayload( - data=file_data, - filename=payload_file.filename, - mime_type=mime_type - ) - else: - # Text message - result = validate_message(message) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - payload = message - - if not day_phrase: - flash('Day phrase is required', 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Read files - ref_data = ref_photo.read() - carrier_data = carrier.read() - - # Handle RSA key - can come from .pem file or QR code image - rsa_key_data = None - rsa_key_qr = request.files.get('rsa_key_qr') - rsa_key_from_qr = False # Track source for password handling - - if rsa_key_file and rsa_key_file.filename: - # RSA key from .pem file - rsa_key_data = rsa_key_file.read() - elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ: - # RSA key from QR code image - qr_image_data = rsa_key_qr.read() - key_pem = extract_key_from_qr(qr_image_data) - if key_pem: - rsa_key_data = key_pem.encode('utf-8') - rsa_key_from_qr = True # QR keys are never password-protected - else: - flash('Could not extract RSA key from QR code image. Make sure the image contains a valid QR code.', 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Validate security factors - result = validate_security_factors(pin, rsa_key_data) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Validate PIN if provided - if pin: - result = validate_pin(pin) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Determine key password - QR code keys are never password-protected - key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) - - # Validate RSA key if provided - if rsa_key_data: - result = validate_rsa_key(rsa_key_data, key_password) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Validate carrier image - result = validate_image(carrier_data, "Carrier image") - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - # Get date - client_date = request.form.get('client_date', '').strip() - if client_date and len(client_date) == 10 and client_date[4] == '-' and client_date[7] == '-': - date_str = client_date - else: - date_str = datetime.now().strftime('%Y-%m-%d') - - # Encode - encode_result = encode( - message=payload, - reference_photo=ref_data, - carrier_image=carrier_data, - day_phrase=day_phrase, - pin=pin, - rsa_key_data=rsa_key_data, - rsa_password=key_password, - date_str=date_str - ) - - # Store temporarily - file_id = secrets.token_urlsafe(16) - cleanup_temp_files() - TEMP_FILES[file_id] = { - 'data': encode_result.stego_image, - 'filename': encode_result.filename, - 'timestamp': time.time() - } - - return redirect(url_for('encode_result', file_id=file_id)) - - except CapacityError as e: - flash(str(e), 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - except StegasooError as e: - flash(str(e), 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - except Exception as e: - flash(f'Error: {e}', 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - - -@app.route('/encode/result/') -def encode_result(file_id): - if file_id not in TEMP_FILES: - flash('File expired or not found. Please encode again.', 'error') - return redirect(url_for('encode_page')) - - file_info = TEMP_FILES[file_id] - return render_template('encode_result.html', - file_id=file_id, - filename=file_info['filename'] - ) - - -@app.route('/encode/download/') -def encode_download(file_id): - if file_id not in TEMP_FILES: - flash('File expired or not found.', 'error') - return redirect(url_for('encode_page')) - - file_info = TEMP_FILES[file_id] - return send_file( - io.BytesIO(file_info['data']), - mimetype='image/png', - as_attachment=True, - download_name=file_info['filename'] - ) - - -@app.route('/encode/file/') -def encode_file_route(file_id): - """Serve file for Web Share API.""" - if file_id not in TEMP_FILES: - return "Not found", 404 - - file_info = TEMP_FILES[file_id] - return send_file( - io.BytesIO(file_info['data']), - mimetype='image/png', - as_attachment=False, - download_name=file_info['filename'] - ) - - -@app.route('/encode/cleanup/', methods=['POST']) -def encode_cleanup(file_id): - """Manually cleanup a file after sharing.""" - TEMP_FILES.pop(file_id, None) - return jsonify({'status': 'ok'}) - - -@app.route('/decode', methods=['GET', 'POST']) -def decode_page(): - if request.method == 'POST': - try: - # Get files - ref_photo = request.files.get('reference_photo') - stego_image = request.files.get('stego_image') - rsa_key_file = request.files.get('rsa_key') - - if not ref_photo or not stego_image: - flash('Both reference photo and stego image are required', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - # Get form data - day_phrase = request.form.get('day_phrase', '') - pin = request.form.get('pin', '').strip() - rsa_password = request.form.get('rsa_password', '') - - if not day_phrase: - flash('Day phrase is required', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - # Read files - ref_data = ref_photo.read() - stego_data = stego_image.read() - - # Handle RSA key - can come from .pem file or QR code image - rsa_key_data = None - rsa_key_qr = request.files.get('rsa_key_qr') - rsa_key_from_qr = False # Track source for password handling - - if rsa_key_file and rsa_key_file.filename: - # RSA key from .pem file - rsa_key_data = rsa_key_file.read() - elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ: - # RSA key from QR code image - qr_image_data = rsa_key_qr.read() - key_pem = extract_key_from_qr(qr_image_data) - if key_pem: - rsa_key_data = key_pem.encode('utf-8') - rsa_key_from_qr = True # QR keys are never password-protected - else: - flash('Could not extract RSA key from QR code image. Make sure the image contains a valid QR code.', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - # Validate security factors - result = validate_security_factors(pin, rsa_key_data) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - # Validate PIN if provided - if pin: - result = validate_pin(pin) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - # Determine key password - QR code keys are never password-protected - key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) - - # Validate RSA key if provided - if rsa_key_data: - result = validate_rsa_key(rsa_key_data, key_password) - if not result.is_valid: - flash(result.error_message, 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - # Decode - decode_result = decode( - stego_image=stego_data, - reference_photo=ref_data, - day_phrase=day_phrase, - pin=pin, - rsa_key_data=rsa_key_data, - rsa_password=key_password - ) - - if decode_result.is_file: - # File content - store temporarily for download - file_id = secrets.token_urlsafe(16) - cleanup_temp_files() - - filename = decode_result.filename or 'decoded_file' - TEMP_FILES[file_id] = { - 'data': decode_result.file_data, - 'filename': filename, - 'mime_type': decode_result.mime_type, - 'timestamp': time.time() - } - - return render_template('decode.html', - decoded_file=True, - file_id=file_id, - filename=filename, - file_size=format_size(len(decode_result.file_data)), - mime_type=decode_result.mime_type - ) - else: - # Text content - return render_template('decode.html', decoded_message=decode_result.message) - - except DecryptionError: - flash('Decryption failed. Check your phrase, PIN, RSA key, and reference photo.', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - except StegasooError as e: - flash(str(e), 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - except Exception as e: - flash(f'Error: {e}', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - - -@app.route('/decode/download/') -def decode_download(file_id): - """Download decoded file.""" - if file_id not in TEMP_FILES: - flash('File expired or not found.', 'error') - return redirect(url_for('decode_page')) - - file_info = TEMP_FILES[file_id] - mime_type = file_info.get('mime_type', 'application/octet-stream') - - return send_file( - io.BytesIO(file_info['data']), - mimetype=mime_type, - as_attachment=True, - download_name=file_info['filename'] - ) - - -@app.route('/about') -def about(): - return render_template('about.html', - has_argon2=has_argon2(), - has_qrcode_read=HAS_QRCODE_READ, - max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024 - ) - - -# ============================================================================ -# MAIN -# ============================================================================ - -if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000, debug=False) diff --git a/frontends/web/static/.logo.svg.kate-swp b/frontends/web/static/.logo.svg.kate-swp deleted file mode 100644 index fb2e36ee8e64832a411580ed6ed15973cb395434..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 32268 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 Ix_aRM0LtF4s{jB1 diff --git a/frontends/web/static/style.css_20251228 b/frontends/web/static/style.css_20251228 deleted file mode 100644 index 7d4e1e9..0000000 --- a/frontends/web/static/style.css_20251228 +++ /dev/null @@ -1,409 +0,0 @@ -/* ============================================================================ - Stegasoo - Main Stylesheet - ============================================================================ */ - -/* ---------------------------------------------------------------------------- - CSS Variables - ---------------------------------------------------------------------------- */ -:root { - --gradient-start: #667eea; - --gradient-end: #764ba2; - --bg-dark-1: #1a1a2e; - --bg-dark-2: #16213e; - --bg-dark-3: #0f3460; - --text-muted: rgba(255, 255, 255, 0.5); - --border-light: rgba(255, 255, 255, 0.1); - --overlay-dark: rgba(0, 0, 0, 0.3); - --overlay-light: rgba(255, 255, 255, 0.05); -} - -/* ---------------------------------------------------------------------------- - Base Styles - ---------------------------------------------------------------------------- */ -body { - min-height: 100vh; - background: linear-gradient(135deg, var(--bg-dark-1) 0%, var(--bg-dark-2) 50%, var(--bg-dark-3) 100%); -} - -/* ---------------------------------------------------------------------------- - Navigation - ---------------------------------------------------------------------------- */ -.navbar { - background: var(--overlay-dark) !important; - backdrop-filter: blur(10px); -} - -/* ---------------------------------------------------------------------------- - Cards - ---------------------------------------------------------------------------- */ -.card { - background: var(--overlay-light); - backdrop-filter: blur(10px); - border: 1px solid var(--border-light); -} - -.card-header { - background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); - border-bottom: none; -} - -.card-link .card-header.text-center { - padding-top: 0.5rem !important; - padding-bottom: 0.5rem !important; - height: 4.5rem; /* Fixed height to match original */ - display: flex; - align-items: center; - justify-content: center; -} - -.feature-card { - transition: transform 0.3s ease, box-shadow 0.3s ease; -} - -.feature-card:hover { - transform: translateY(-5px); - box-shadow: 0 10px 40px rgba(102, 126, 234, 0.2); -} - -/* ---------------------------------------------------------------------------- - Buttons - ---------------------------------------------------------------------------- */ -.btn-primary { - background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); - border: none; -} - -.btn-primary:hover { - background: linear-gradient(135deg, var(--gradient-end), var(--gradient-start)); - transform: translateY(-2px); - box-shadow: 0 5px 20px rgba(102, 126, 234, 0.4); -} - -/* ---------------------------------------------------------------------------- - Forms - ---------------------------------------------------------------------------- */ -.form-control, -.form-select { - background: var(--overlay-light); - border: 1px solid var(--border-light); - color: #fff; -} - -.form-control:focus, -.form-select:focus { - background: rgba(255, 255, 255, 0.1); - border-color: var(--gradient-start); - box-shadow: 0 0 0 0.25rem rgba(102, 126, 234, 0.25); - color: #fff; -} - -.form-control::placeholder { - color: var(--text-muted); -} - -/* Fix dropdown options for dark theme */ -.form-select option { - background: var(--bg-dark-1); - color: #fff; -} - -/* ---------------------------------------------------------------------------- - Hero & Icons - ---------------------------------------------------------------------------- */ -.hero-icon { - font-size: 4rem; - background: linear-gradient(135deg, var(--gradient-start), var(--gradient-end)); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; -} - -/* ---------------------------------------------------------------------------- - Phrase Display - ---------------------------------------------------------------------------- */ -.phrase-display { - font-family: 'Courier New', monospace; - font-size: 1rem; - background: var(--overlay-dark); - padding: 0.5rem 0.75rem; - border-radius: 0.5rem; - border-left: 4px solid var(--gradient-start); - display: inline-block; - line-height: 1.6; - word-spacing: 0.3rem; -} - -/* ---------------------------------------------------------------------------- - PIN Display - ---------------------------------------------------------------------------- */ -.pin-display { - font-family: 'Courier New', monospace; - font-size: 3rem; - font-weight: bold; - letter-spacing: 0.75rem; - background: linear-gradient(135deg, #fef08a, #fcd34d, #fb923c); - -webkit-background-clip: text; - -webkit-text-fill-color: transparent; - background-clip: text; - display: inline-block; - line-height: 1; -} - -.pin-container { - background: var(--overlay-dark); - border: 1px solid var(--border-light); - border-radius: 0.75rem; - padding: 1.5rem 2rem; - display: inline-block; -} - -/* ---------------------------------------------------------------------------- - Story Cards (Memory Aid) - ---------------------------------------------------------------------------- */ -.story-word { - color: #ff6b6b; - font-weight: bold; - text-transform: uppercase; -} - -.story-card { - background: rgba(0, 0, 0, 0.2); - border-left: 3px solid var(--gradient-start); - padding: 1rem; - margin-bottom: 0.75rem; - border-radius: 0.5rem; - font-size: 0.95rem; - line-height: 1.6; -} - -.story-card .day-label { - font-weight: bold; - color: var(--gradient-start); - margin-bottom: 0.5rem; -} - -/* ---------------------------------------------------------------------------- - Alert / Message Display - ---------------------------------------------------------------------------- */ -.alert-message { - background: var(--overlay-dark); - border: 1px solid var(--border-light); - border-radius: 0.5rem; - padding: 1.5rem; - white-space: pre-wrap; - font-family: 'Courier New', monospace; -} - -/* ---------------------------------------------------------------------------- - Drop Zone (Drag & Drop File Upload) - ---------------------------------------------------------------------------- */ -.drop-zone { - position: relative; - border: 2px dashed rgba(255, 255, 255, 0.2); - border-radius: 0.5rem; - padding: 1.5rem; - text-align: center; - transition: all 0.2s ease; -} - -.drop-zone.drag-over { - border-color: var(--gradient-start); - background: rgba(102, 126, 234, 0.1); -} - -.drop-zone input[type="file"] { - position: absolute; - inset: 0; - opacity: 0; - cursor: pointer; -} - -.drop-zone-label { - pointer-events: none; -} - -.drop-zone-preview { - max-height: 120px; - border-radius: 0.375rem; - margin-top: 0.75rem; -} - -/* ---------------------------------------------------------------------------- - Footer - ---------------------------------------------------------------------------- */ -footer { - background: rgba(0, 0, 0, 0.2); -} - -/* ---------------------------------------------------------------------------- - Custom Alert Variants - ---------------------------------------------------------------------------- */ -.alert-success-bright { - background: rgba(34, 197, 94, 0.2); - border-color: #22c55e; - color: #4ade80; -} - -/* ---------------------------------------------------------------------------- - Utility Classes - ---------------------------------------------------------------------------- */ -.bg-dark-subtle { - background: rgba(0, 0, 0, 0.2); -} - -.status-box { - background: rgba(0, 0, 0, 0.2); - padding: 1rem; - border-radius: 0.5rem; -} - -.result-icon { - font-size: 4rem; -} - -.footer-icon { - vertical-align: text-bottom; -} - - - -/* ---------------------------------------------------------------------------- - Card Stuff Icons - ---------------------------------------------------------------------------- */ - - -.action-card { - transition: transform 0.2s ease, box-shadow 0.2s ease; -} - -.action-card:hover { - transform: translateY(-5px); - box-shadow: 0 10px 20px rgba(0,0,0,0.1) !important; -} - -.feature-card { - border-radius: 10px; - overflow: hidden; -} - -/* Ensure buttons are easily tappable on mobile */ -@media (max-width: 768px) { - .btn-lg { - padding: 1rem 1.5rem; - font-size: 1.1rem; - } - - .card { - margin-bottom: 1rem; - } -} - -/* ---------------------------------------------------------------------------- - Ethereal Gold Glow + Soft Emboss - ---------------------------------------------------------------------------- */ -.embossed-icon { - color: rgba(255, 255, 255, 0.95) !important; - text-shadow: - /* Layered gold glow - very feathered and transparent */ - 0 0 15px rgba(255, 215, 0, 0.4), - 0 0 30px rgba(255, 215, 0, 0.25), - 0 0 45px rgba(255, 215, 0, 0.15), - 0 0 60px rgba(255, 215, 0, 0.08), - - /* Soft emboss with minimal contrast */ - 0 0.5px 0 rgba(255, 255, 255, 0.4), - 0 -0.5px 0 rgba(0, 0, 0, 0.1), - - /* Extra glow layers for diffusion */ - 0 0 0 1px rgba(255, 215, 0, 0.1); - - position: relative; - display: inline-block; - transition: all 0.4s ease; - font-size: 3.5rem !important; - line-height: 1; - padding: 10px 0; - filter: - drop-shadow(0 0 10px rgba(255, 215, 0, 0.15)) - drop-shadow(0 0 20px rgba(255, 215, 0, 0.1)) - drop-shadow(0 0 30px rgba(255, 215, 0, 0.05)); -} - -.card-link:hover .embossed-icon { - color: rgba(255, 255, 255, 1) !important; - text-shadow: - 0 0 20px rgba(255, 215, 0, 0.5), - 0 0 40px rgba(255, 215, 0, 0.35), - 0 0 60px rgba(255, 215, 0, 0.2), - 0 0 80px rgba(255, 215, 0, 0.1), - 0 0.5px 0 rgba(255, 255, 255, 0.5), - 0 -0.5px 0 rgba(0, 0, 0, 0.15), - 0 0 0 1px rgba(255, 215, 0, 0.15); - - filter: - drop-shadow(0 0 15px rgba(255, 215, 0, 0.2)) - drop-shadow(0 0 30px rgba(255, 215, 0, 0.15)) - drop-shadow(0 0 45px rgba(255, 215, 0, 0.08)); -} - -/* Adjust card-header padding to give icons more vertical breathing room */ -.card-link .card-header.text-center { - padding-top: 1rem !important; /* Increased from 0.75rem */ - padding-bottom: 1rem !important; /* Increased from 0.75rem */ - min-height: 6rem; /* Increased from 5.5rem to accommodate padding */ - display: flex; - align-items: center; - justify-content: center; -} - -/* Alternatively, you could use relative padding based on icon size */ -/* This version makes the padding proportional to the icon size */ -/* -.card-link .card-header.text-center { - padding-top: calc(1rem + 0.5vh) !important; - padding-bottom: calc(1rem + 0.5vh) !important; - min-height: auto !important; - display: flex; - align-items: center; - justify-content: center; -} -*/ - -/* Mobile adjustments for larger icons */ -@media (max-width: 768px) { - .embossed-icon { - font-size: 2.8rem !important; - padding: 6px 0; /* Slightly less padding on mobile */ - } - - .card-link .card-header.text-center { - padding-top: 0.75rem !important; - padding-bottom: 0.75rem !important; - min-height: 5rem; /* Adjusted for mobile */ - } -} - - -/* ---------------------------------------------------------------------------- - Card Links - ---------------------------------------------------------------------------- */ -.card-link { - text-decoration: none; - color: inherit; - display: block; - height: 100%; -} - -.card-link:hover { - color: inherit; -} - -/* Optional: Add a slight scale effect to the entire card on hover */ -.card-link .feature-card { - transition: transform 0.3s ease, box-shadow 0.3s ease; -} - -.card-link:hover .feature-card { - transform: translateY(-5px); - box-shadow: 0 10px 40px rgba(102, 126, 234, 0.3); -} diff --git a/frontends/web/templates/encode_result.html_20251228 b/frontends/web/templates/encode_result.html_20251228 deleted file mode 100644 index a691ab3..0000000 --- a/frontends/web/templates/encode_result.html_20251228 +++ /dev/null @@ -1,95 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Encode Success - Stegasoo{% endblock %} - -{% block content %} -
-
-
-
-
Encoding Successful!
-
-
-
- -
- -

Your secret has been hidden in the image.

- -
- {{ filename }} -
- -
- - Download Image - - - -
- -
- -
- - Important: -
    -
  • This file expires in 5 minutes
  • -
  • Do not resize or recompress the image
  • -
  • PNG format preserves your hidden data
  • -
-
- - - Encode Another Message - -
-
-
-
-{% endblock %} - -{% block scripts %} - -{% endblock %} diff --git a/frontends/web/templates/index.html_20251228 b/frontends/web/templates/index.html_20251228 deleted file mode 100644 index 19deb0e..0000000 --- a/frontends/web/templates/index.html_20251228 +++ /dev/null @@ -1,108 +0,0 @@ -{% extends "base.html" %} - -{% block title %}Stegasoo - Secure Steganography{% endblock %} - -{% block content %} -
- Stegasoo -

Stegasoo

-

Create hidden encrypted messages in images and photos using advanced steganography.

-
-
- -
-
-
- -
-
-
Encode Message
-

- Hide your secret message inside an innocent-looking image using your daily phrase + PIN. -

- - Encode - -
-
-
- -
-
-
- -
-
-
Decode Message
-

- Extract and decrypt hidden messages from Stegasoo-encoded images using your credentials. -

- - Decode - -
-
-
- -
-
-
- -
-
-
Generate Keys
-

- Create your weekly phrase card and PIN. Memorize 21 words + 6 digits for maximum security. -

- - Generate - -
-
-
-
- -
-
-
How It Works
-
-
-
-
-
Key Components
-
    -
  • - - Reference Photo — Any photo you and recipient both have -
  • -
  • - - Day Phrase — 3 words, different each day of the week -
  • -
  • - - Static PIN — 6 digits, same every day -
  • -
-
-
-
Security Features
-
    -
  • - - Argon2id memory-hard key derivation (256MB) -
  • -
  • - - Pseudo-random pixel selection (defeats steganalysis) -
  • -
  • - - AES-256-GCM authenticated encryption -
  • -
-
-
-
-
-{% endblock %} diff --git a/src_20251228/__init__.py b/src_20251228/__init__.py deleted file mode 100644 index e69de29..0000000 diff --git a/src_20251228/main.py b/src_20251228/main.py deleted file mode 100644 index fcb14ea..0000000 --- a/src_20251228/main.py +++ /dev/null @@ -1,11 +0,0 @@ -#!/usr/bin/env python3 -"""Main entry point.""" - - -def main(): - """Main function.""" - print("Hello, World!") - - -if __name__ == "__main__": - main() diff --git a/src_20251228/stegasoo/__init__.py b/src_20251228/stegasoo/__init__.py deleted file mode 100644 index bfb7a5f..0000000 --- a/src_20251228/stegasoo/__init__.py +++ /dev/null @@ -1,577 +0,0 @@ -""" -Stegasoo - Secure Steganography Library - -A Python library for hiding encrypted messages and files in images using -hybrid photo + passphrase + PIN authentication. - -Basic Usage - Text Message: - from stegasoo import encode, decode, generate_credentials - - # Generate credentials - creds = generate_credentials(use_pin=True, use_rsa=False) - print(creds.phrases['Monday']) - print(creds.pin) - - # Encode a message - with open('secret.jpg', 'rb') as f: - ref_photo = f.read() - with open('meme.png', 'rb') as f: - carrier = f.read() - - result = encode( - message="Meet at midnight", - reference_photo=ref_photo, - carrier_image=carrier, - day_phrase="apple forest thunder", - pin="123456" - ) - - with open('stego.png', 'wb') as f: - f.write(result.stego_image) - - # Decode a message - decoded = decode( - stego_image=result.stego_image, - reference_photo=ref_photo, - day_phrase="apple forest thunder", - pin="123456" - ) - print(decoded.message) # "Meet at midnight" - -File Embedding: - from stegasoo import encode_file, decode, FilePayload - - # Encode a file - result = encode_file( - filepath="secret_document.pdf", - reference_photo=ref_photo, - carrier_image=carrier, - day_phrase="apple forest thunder", - pin="123456" - ) - - # Decode - automatically detects file vs text - decoded = decode(...) - if decoded.is_file: - with open(decoded.filename, 'wb') as f: - f.write(decoded.file_data) - else: - print(decoded.message) -""" - -from .constants import __version__, DAY_NAMES, MAX_MESSAGE_SIZE, MAX_FILE_PAYLOAD_SIZE -from .models import ( - Credentials, - EncodeInput, - EncodeResult, - DecodeInput, - DecodeResult, - EmbedStats, - KeyInfo, - ValidationResult, - FilePayload, -) -from .exceptions import ( - StegasooError, - ValidationError, - PinValidationError, - MessageValidationError, - ImageValidationError, - KeyValidationError, - SecurityFactorError, - CryptoError, - EncryptionError, - DecryptionError, - KeyDerivationError, - KeyGenerationError, - KeyPasswordError, - SteganographyError, - CapacityError, - ExtractionError, - EmbeddingError, - InvalidHeaderError, -) -from .keygen import ( - generate_credentials, - generate_pin, - generate_phrase, - generate_day_phrases, - generate_rsa_key, - export_rsa_key_pem, - load_rsa_key, - get_key_info, -) -from .validation import ( - validate_pin, - validate_message, - validate_payload, - validate_file_payload, - validate_image, - validate_rsa_key, - validate_security_factors, - validate_phrase, - validate_date_string, - require_valid_pin, - require_valid_message, - require_valid_payload, - require_valid_image, - require_valid_rsa_key, - require_security_factors, -) -from .crypto import ( - encrypt_message, - decrypt_message, - decrypt_message_text, - derive_hybrid_key, - derive_pixel_key, - hash_photo, - parse_header, - get_date_from_encrypted, - has_argon2, -) -from .steganography import ( - embed_in_image, - extract_from_image, - calculate_capacity, - get_image_dimensions, - get_image_format, - is_lossless_format, - LOSSLESS_FORMATS, -) -from .utils import ( - generate_filename, - parse_date_from_filename, - get_day_from_date, - get_today_date, - get_today_day, - secure_delete, - SecureDeleter, - format_file_size, -) - -# QR Code utilities (optional, depends on qrcode and pyzbar) -try: - from .qr_utils import ( - generate_qr_code, - read_qr_code, - read_qr_code_from_file, - extract_key_from_qr, - extract_key_from_qr_file, - compress_data, - decompress_data, - auto_decompress, - normalize_pem, - is_compressed, - can_fit_in_qr, - needs_compression, - has_qr_read, - has_qr_write, - has_qr_support, - ) - HAS_QR_UTILS = True -except ImportError: - HAS_QR_UTILS = False - -from datetime import date -from pathlib import Path -from typing import Optional, Union - - -def encode( - message: Union[str, bytes, FilePayload], - reference_photo: bytes, - carrier_image: bytes, - day_phrase: str, - pin: str = "", - rsa_key_data: Optional[bytes] = None, - rsa_password: Optional[str] = None, - date_str: Optional[str] = None, - output_format: Optional[str] = None, -) -> EncodeResult: - """ - Encode a secret message or file into an image. - - High-level convenience function that handles validation, - encryption, and embedding in one call. - - Args: - message: Secret message (str), raw bytes, or FilePayload to hide - reference_photo: Shared reference photo bytes - carrier_image: Image to hide message in - day_phrase: Today's passphrase - pin: Static PIN (optional if using RSA key) - rsa_key_data: RSA private key PEM bytes (optional if using PIN) - rsa_password: Password for RSA key if encrypted - date_str: Date string YYYY-MM-DD (defaults to today) - output_format: Force output format ('PNG', 'BMP'). If None, preserves - carrier format for lossless types, defaults to PNG for lossy. - - Returns: - EncodeResult with stego image and metadata - - Raises: - ValidationError: If inputs are invalid - SecurityFactorError: If no PIN or RSA key provided - CapacityError: If carrier is too small - EncryptionError: If encryption fails - - Note: - Output format is always lossless (PNG or BMP) to preserve hidden data. - If carrier is JPEG/GIF, output will be PNG to maintain data integrity. - """ - # Validate inputs - require_valid_payload(message) - require_valid_image(carrier_image, "Carrier image") - require_security_factors(pin, rsa_key_data) - - if pin: - require_valid_pin(pin) - if rsa_key_data: - require_valid_rsa_key(rsa_key_data, rsa_password) - - # Default date to today - if date_str is None: - date_str = date.today().isoformat() - - # Encrypt message/file - encrypted = encrypt_message( - message, reference_photo, day_phrase, date_str, pin, rsa_key_data - ) - - # Get pixel key - pixel_key = derive_pixel_key( - reference_photo, day_phrase, date_str, pin, rsa_key_data - ) - - # Embed in image (returns extension too) - stego_data, stats, extension = embed_in_image( - carrier_image, encrypted, pixel_key, output_format=output_format - ) - - # Generate filename with correct extension - filename = generate_filename(date_str, extension=extension) - - return EncodeResult( - stego_image=stego_data, - filename=filename, - pixels_modified=stats.pixels_modified, - total_pixels=stats.total_pixels, - capacity_used=stats.capacity_used, - date_used=date_str - ) - - -def encode_file( - filepath: Union[str, Path], - reference_photo: bytes, - carrier_image: bytes, - day_phrase: str, - pin: str = "", - rsa_key_data: Optional[bytes] = None, - rsa_password: Optional[str] = None, - date_str: Optional[str] = None, - output_format: Optional[str] = None, - filename_override: Optional[str] = None, -) -> EncodeResult: - """ - Encode a file into an image. - - Convenience function for embedding files. Preserves original filename. - - Args: - filepath: Path to file to embed - reference_photo: Shared reference photo bytes - carrier_image: Image to hide file in - day_phrase: Today's passphrase - pin: Static PIN (optional if using RSA key) - rsa_key_data: RSA private key PEM bytes (optional if using PIN) - rsa_password: Password for RSA key if encrypted - date_str: Date string YYYY-MM-DD (defaults to today) - output_format: Force output format ('PNG', 'BMP') - filename_override: Override the stored filename - - Returns: - EncodeResult with stego image and metadata - """ - payload = FilePayload.from_file(str(filepath), filename_override) - - return encode( - message=payload, - reference_photo=reference_photo, - carrier_image=carrier_image, - day_phrase=day_phrase, - pin=pin, - rsa_key_data=rsa_key_data, - rsa_password=rsa_password, - date_str=date_str, - output_format=output_format, - ) - - -def encode_bytes( - data: bytes, - filename: str, - reference_photo: bytes, - carrier_image: bytes, - day_phrase: str, - pin: str = "", - rsa_key_data: Optional[bytes] = None, - rsa_password: Optional[str] = None, - date_str: Optional[str] = None, - output_format: Optional[str] = None, - mime_type: Optional[str] = None, -) -> EncodeResult: - """ - Encode raw bytes with a filename into an image. - - Convenience function for embedding binary data with metadata. - - Args: - data: Raw bytes to embed - filename: Filename to associate with the data - reference_photo: Shared reference photo bytes - carrier_image: Image to hide data in - day_phrase: Today's passphrase - pin: Static PIN (optional if using RSA key) - rsa_key_data: RSA private key PEM bytes (optional if using PIN) - rsa_password: Password for RSA key if encrypted - date_str: Date string YYYY-MM-DD (defaults to today) - output_format: Force output format ('PNG', 'BMP') - mime_type: MIME type of the data - - Returns: - EncodeResult with stego image and metadata - """ - payload = FilePayload(data=data, filename=filename, mime_type=mime_type) - - return encode( - message=payload, - reference_photo=reference_photo, - carrier_image=carrier_image, - day_phrase=day_phrase, - pin=pin, - rsa_key_data=rsa_key_data, - rsa_password=rsa_password, - date_str=date_str, - output_format=output_format, - ) - - -def decode( - stego_image: bytes, - reference_photo: bytes, - day_phrase: str, - pin: str = "", - rsa_key_data: Optional[bytes] = None, - rsa_password: Optional[str] = None, -) -> DecodeResult: - """ - Decode a secret message or file from a stego image. - - High-level convenience function that handles extraction - and decryption in one call. - - Args: - stego_image: Image containing hidden message/file - reference_photo: Shared reference photo bytes - day_phrase: Passphrase for the day message was encoded - pin: Static PIN (if used during encoding) - rsa_key_data: RSA private key PEM bytes (if used during encoding) - rsa_password: Password for RSA key if encrypted - - Returns: - DecodeResult with: - - .payload_type: 'text' or 'file' - - .message: Decoded text (if text) - - .file_data: Decoded bytes (if file) - - .filename: Original filename (if file) - - .is_text / .is_file: Convenience properties - - Raises: - ValidationError: If inputs are invalid - SecurityFactorError: If no PIN or RSA key provided - ExtractionError: If data cannot be extracted - DecryptionError: If decryption fails - """ - # Validate inputs - require_security_factors(pin, rsa_key_data) - - if pin: - require_valid_pin(pin) - if rsa_key_data: - require_valid_rsa_key(rsa_key_data, rsa_password) - - # Try to extract with today's date first - date_str = date.today().isoformat() - pixel_key = derive_pixel_key( - reference_photo, day_phrase, date_str, pin, rsa_key_data - ) - - encrypted = extract_from_image(stego_image, pixel_key) - - # If we got data, check if it's from a different date - if encrypted: - header = parse_header(encrypted) - if header and header['date'] != date_str: - # Re-extract with correct date - pixel_key = derive_pixel_key( - reference_photo, day_phrase, header['date'], pin, rsa_key_data - ) - encrypted = extract_from_image(stego_image, pixel_key) - - if not encrypted: - raise ExtractionError("Could not extract data. Check your inputs.") - - # Decrypt and return full result - return decrypt_message(encrypted, reference_photo, day_phrase, pin, rsa_key_data) - - -def decode_text( - stego_image: bytes, - reference_photo: bytes, - day_phrase: str, - pin: str = "", - rsa_key_data: Optional[bytes] = None, - rsa_password: Optional[str] = None, -) -> str: - """ - Decode a text message from a stego image. - - Convenience function that returns just the text string. - Raises an error if the content is a binary file. - - Args: - stego_image: Image containing hidden message - reference_photo: Shared reference photo bytes - day_phrase: Passphrase for the day message was encoded - pin: Static PIN (if used during encoding) - rsa_key_data: RSA private key PEM bytes (if used during encoding) - rsa_password: Password for RSA key if encrypted - - Returns: - Decrypted message string - - Raises: - DecryptionError: If content is a binary file, not text - """ - result = decode(stego_image, reference_photo, day_phrase, pin, rsa_key_data, rsa_password) - - if result.is_file: - # Try to decode file as text - if result.file_data: - try: - return result.file_data.decode('utf-8') - except UnicodeDecodeError: - raise DecryptionError( - f"Content is a binary file ({result.filename or 'unnamed'}), not text. " - "Use decode() instead and check result.is_file." - ) - return "" - - return result.message or "" - - -__all__ = [ - # Version - '__version__', - - # High-level API - 'encode', - 'encode_file', - 'encode_bytes', - 'decode', - 'decode_text', - 'generate_credentials', - - # Constants - 'DAY_NAMES', - 'LOSSLESS_FORMATS', - 'MAX_MESSAGE_SIZE', - 'MAX_FILE_PAYLOAD_SIZE', - - # Models - 'Credentials', - 'EncodeInput', - 'EncodeResult', - 'DecodeInput', - 'DecodeResult', - 'EmbedStats', - 'KeyInfo', - 'ValidationResult', - 'FilePayload', - - # Exceptions - 'StegasooError', - 'ValidationError', - 'PinValidationError', - 'MessageValidationError', - 'ImageValidationError', - 'KeyValidationError', - 'SecurityFactorError', - 'CryptoError', - 'EncryptionError', - 'DecryptionError', - 'KeyDerivationError', - 'KeyGenerationError', - 'KeyPasswordError', - 'SteganographyError', - 'CapacityError', - 'ExtractionError', - 'EmbeddingError', - 'InvalidHeaderError', - - # Key generation - 'generate_pin', - 'generate_phrase', - 'generate_day_phrases', - 'generate_rsa_key', - 'export_rsa_key_pem', - 'load_rsa_key', - 'get_key_info', - - # Validation - 'validate_pin', - 'validate_message', - 'validate_payload', - 'validate_file_payload', - 'validate_image', - 'validate_rsa_key', - 'validate_security_factors', - 'validate_phrase', - 'validate_date_string', - 'require_valid_pin', - 'require_valid_message', - 'require_valid_payload', - 'require_valid_image', - 'require_valid_rsa_key', - 'require_security_factors', - - # Crypto - 'encrypt_message', - 'decrypt_message', - 'decrypt_message_text', - 'derive_hybrid_key', - 'derive_pixel_key', - 'hash_photo', - 'parse_header', - 'get_date_from_encrypted', - 'has_argon2', - - # Steganography - 'embed_in_image', - 'extract_from_image', - 'calculate_capacity', - 'get_image_dimensions', - 'get_image_format', - 'is_lossless_format', - - # Utilities - 'generate_filename', - 'parse_date_from_filename', - 'get_day_from_date', - 'get_today_date', - 'get_today_day', - 'secure_delete', - 'SecureDeleter', - 'format_file_size', -] diff --git a/src_20251228/stegasoo/cli.py b/src_20251228/stegasoo/cli.py deleted file mode 100644 index a3ebab0..0000000 --- a/src_20251228/stegasoo/cli.py +++ /dev/null @@ -1,66 +0,0 @@ -""" -Stegasoo CLI - Command-line interface for steganography operations. - -This is the package entry point. For full CLI, install with: pip install stegasoo[cli] -""" - -def main(): - """Main entry point for the CLI.""" - try: - import click - except ImportError: - print("CLI requires click. Install with: pip install stegasoo[cli]") - return 1 - - # Import the CLI from frontends - import sys - from pathlib import Path - - # Add frontends to path for development - root = Path(__file__).parent.parent.parent - cli_path = root / 'frontends' / 'cli' - if cli_path.exists(): - sys.path.insert(0, str(cli_path)) - - try: - from main import cli - cli() - except ImportError: - # Minimal fallback CLI - _minimal_cli() - - -def _minimal_cli(): - """Minimal CLI when full CLI is not available.""" - import sys - from . import __version__, generate_credentials, DAY_NAMES - - if len(sys.argv) < 2 or sys.argv[1] in ['-h', '--help']: - print(f"Stegasoo v{__version__} - Secure Steganography") - print() - print("Usage: stegasoo ") - print() - print("Commands:") - print(" generate Generate credentials") - print(" encode Encode a message (requires full CLI)") - print(" decode Decode a message (requires full CLI)") - print() - print("For full CLI functionality:") - print(" pip install stegasoo[cli]") - return - - if sys.argv[1] == 'generate': - creds = generate_credentials(use_pin=True, use_rsa=False) - print("\n=== STEGASOO CREDENTIALS ===\n") - print(f"PIN: {creds.pin}\n") - print("Daily Phrases:") - for day in DAY_NAMES: - print(f" {day:9} | {creds.phrases[day]}") - print(f"\nEntropy: {creds.total_entropy} bits (+ photo)") - else: - print(f"Command '{sys.argv[1]}' requires full CLI.") - print("Install with: pip install stegasoo[cli]") - - -if __name__ == '__main__': - main() diff --git a/src_20251228/stegasoo/constants.py b/src_20251228/stegasoo/constants.py deleted file mode 100644 index b28364a..0000000 --- a/src_20251228/stegasoo/constants.py +++ /dev/null @@ -1,131 +0,0 @@ -""" -Stegasoo Constants and Configuration - -Central location for all magic numbers, limits, and crypto parameters. -""" - -import os -from pathlib import Path - -# ============================================================================ -# VERSION -# ============================================================================ - -__version__ = "2.1.1" - -# ============================================================================ -# FILE FORMAT -# ============================================================================ - -MAGIC_HEADER = b'\x89ST3' -FORMAT_VERSION = 3 - -# Payload type markers -PAYLOAD_TEXT = 0x01 -PAYLOAD_FILE = 0x02 - -# ============================================================================ -# CRYPTO PARAMETERS -# ============================================================================ - -SALT_SIZE = 32 -IV_SIZE = 12 -TAG_SIZE = 16 - -# Argon2 parameters (memory-hard KDF) -ARGON2_TIME_COST = 4 -ARGON2_MEMORY_COST = 256 * 1024 # 256 MB -ARGON2_PARALLELISM = 4 - -# PBKDF2 fallback parameters -PBKDF2_ITERATIONS = 600000 - -# ============================================================================ -# INPUT LIMITS -# ============================================================================ - -MAX_IMAGE_PIXELS = 16_000_000 # ~16 megapixels (4000x4000) -MAX_MESSAGE_SIZE = 250_000 # 250 KB (text messages) -MAX_FILE_PAYLOAD_SIZE = 250_000 # 250 KB (file payloads) -MAX_FILENAME_LENGTH = 255 # Max filename length to store -MAX_FILE_SIZE = 10 * 1024 * 1024 # 10 MB (upload limit) - -MIN_PIN_LENGTH = 6 -MAX_PIN_LENGTH = 9 -DEFAULT_PIN_LENGTH = 6 - -MIN_PHRASE_WORDS = 3 -MAX_PHRASE_WORDS = 12 -DEFAULT_PHRASE_WORDS = 3 - -MIN_RSA_BITS = 2048 -VALID_RSA_SIZES = (2048, 3072, 4096) -DEFAULT_RSA_BITS = 2048 - -MIN_KEY_PASSWORD_LENGTH = 8 - -# ============================================================================ -# FILE TYPES -# ============================================================================ - -ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'bmp', 'gif'} -ALLOWED_KEY_EXTENSIONS = {'pem', 'key'} - -# ============================================================================ -# DAYS -# ============================================================================ - -DAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday') - -# ============================================================================ -# DATA FILES -# ============================================================================ - -def get_data_dir() -> Path: - """Get the data directory path.""" - # Check multiple locations - # From src/stegasoo/constants.py: - # .parent = src/stegasoo/ - # .parent.parent = src/ - # .parent.parent.parent = project root (where data/ lives) - candidates = [ - Path(__file__).parent.parent.parent / 'data', # Development: src/stegasoo -> project root - Path(__file__).parent / 'data', # Installed package - Path('/app/data'), # Docker - Path.cwd() / 'data', # Current directory - Path.cwd().parent / 'data', # One level up from cwd - Path.cwd().parent.parent / 'data', # Two levels up from cwd - ] - - for path in candidates: - if path.exists(): - return path - - # Default to first candidate - return candidates[0] - - -def get_bip39_words() -> list[str]: - """Load BIP-39 wordlist.""" - wordlist_path = get_data_dir() / 'bip39-words.txt' - - if not wordlist_path.exists(): - raise FileNotFoundError( - f"BIP-39 wordlist not found at {wordlist_path}. " - "Please ensure bip39-words.txt is in the data directory." - ) - - with open(wordlist_path, 'r') as f: - return [line.strip() for line in f if line.strip()] - - -# Lazy-loaded wordlist -_bip39_words: list[str] | None = None - - -def get_wordlist() -> list[str]: - """Get the BIP-39 wordlist (cached).""" - global _bip39_words - if _bip39_words is None: - _bip39_words = get_bip39_words() - return _bip39_words diff --git a/src_20251228/stegasoo/crypto.py b/src_20251228/stegasoo/crypto.py deleted file mode 100644 index 15aa98f..0000000 --- a/src_20251228/stegasoo/crypto.py +++ /dev/null @@ -1,512 +0,0 @@ -""" -Stegasoo Cryptographic Functions - -Key derivation, encryption, and decryption using AES-256-GCM. -Supports both text messages and binary file payloads. -""" - -import io -import hashlib -import secrets -import struct -import json -from typing import Optional, Union - -from PIL import Image -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes -from cryptography.hazmat.backends import default_backend - -from .constants import ( - MAGIC_HEADER, FORMAT_VERSION, - SALT_SIZE, IV_SIZE, TAG_SIZE, - ARGON2_TIME_COST, ARGON2_MEMORY_COST, ARGON2_PARALLELISM, - PBKDF2_ITERATIONS, - PAYLOAD_TEXT, PAYLOAD_FILE, - MAX_FILENAME_LENGTH, -) -from .models import FilePayload, DecodeResult -from .exceptions import ( - EncryptionError, DecryptionError, KeyDerivationError, InvalidHeaderError -) - -# Check for Argon2 availability -try: - from argon2.low_level import hash_secret_raw, Type - HAS_ARGON2 = True -except ImportError: - HAS_ARGON2 = False - from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC - from cryptography.hazmat.primitives import hashes - - -def hash_photo(image_data: bytes) -> bytes: - """ - Compute deterministic hash of photo pixel content. - - This normalizes the image to RGB and hashes the raw pixel data, - making it resistant to metadata changes. - - Args: - image_data: Raw image file bytes - - Returns: - 32-byte SHA-256 hash - """ - img = Image.open(io.BytesIO(image_data)) - img = img.convert('RGB') - pixels = img.tobytes() - - # Double-hash with prefix for additional mixing - h = hashlib.sha256(pixels).digest() - h = hashlib.sha256(h + pixels[:1024]).digest() - return h - - -def derive_hybrid_key( - photo_data: bytes, - day_phrase: str, - date_str: str, - salt: bytes, - pin: str = "", - rsa_key_data: Optional[bytes] = None -) -> bytes: - """ - Derive encryption key from multiple factors. - - Combines: - - Photo hash (something you have) - - Day phrase (something you know, rotates daily) - - PIN (something you know, static) - - RSA key (something you have) - - Date (automatic rotation) - - Salt (random per message) - - Uses Argon2id if available, falls back to PBKDF2. - - Args: - photo_data: Reference photo bytes - day_phrase: The day's phrase - date_str: Date string (YYYY-MM-DD) - salt: Random salt for this message - pin: Optional static PIN - rsa_key_data: Optional RSA key bytes - - Returns: - 32-byte derived key - - Raises: - KeyDerivationError: If key derivation fails - """ - try: - photo_hash = hash_photo(photo_data) - - key_material = ( - photo_hash + - day_phrase.lower().encode() + - pin.encode() + - date_str.encode() + - salt - ) - - # Add RSA key hash if provided - if rsa_key_data: - key_material += hashlib.sha256(rsa_key_data).digest() - - if HAS_ARGON2: - key = hash_secret_raw( - secret=key_material, - salt=salt[:32], - time_cost=ARGON2_TIME_COST, - memory_cost=ARGON2_MEMORY_COST, - parallelism=ARGON2_PARALLELISM, - hash_len=32, - type=Type.ID - ) - else: - kdf = PBKDF2HMAC( - algorithm=hashes.SHA512(), - length=32, - salt=salt, - iterations=PBKDF2_ITERATIONS, - backend=default_backend() - ) - key = kdf.derive(key_material) - - return key - - except Exception as e: - raise KeyDerivationError(f"Failed to derive key: {e}") from e - - -def derive_pixel_key( - photo_data: bytes, - day_phrase: str, - date_str: str, - pin: str = "", - rsa_key_data: Optional[bytes] = None -) -> bytes: - """ - Derive key for pseudo-random pixel selection. - - This key determines which pixels are used for embedding, - making the message location unpredictable without the correct inputs. - - Args: - photo_data: Reference photo bytes - day_phrase: The day's phrase - date_str: Date string (YYYY-MM-DD) - pin: Optional static PIN - rsa_key_data: Optional RSA key bytes - - Returns: - 32-byte key for pixel selection - """ - photo_hash = hash_photo(photo_data) - - material = ( - photo_hash + - day_phrase.lower().encode() + - pin.encode() + - date_str.encode() - ) - - if rsa_key_data: - material += hashlib.sha256(rsa_key_data).digest() - - return hashlib.sha256(material + b"pixel_selection").digest() - - -def _pack_payload( - content: Union[str, bytes, FilePayload], -) -> tuple[bytes, int]: - """ - Pack payload with type marker and metadata. - - Format for text: - [type:1][data] - - Format for file: - [type:1][filename_len:2][filename][mime_len:2][mime][data] - - Args: - content: Text string, raw bytes, or FilePayload - - Returns: - Tuple of (packed bytes, payload type) - """ - if isinstance(content, str): - # Text message - data = content.encode('utf-8') - return bytes([PAYLOAD_TEXT]) + data, PAYLOAD_TEXT - - elif isinstance(content, FilePayload): - # File with metadata - filename = content.filename[:MAX_FILENAME_LENGTH].encode('utf-8') - mime = (content.mime_type or '')[:100].encode('utf-8') - - packed = ( - bytes([PAYLOAD_FILE]) + - struct.pack('>H', len(filename)) + - filename + - struct.pack('>H', len(mime)) + - mime + - content.data - ) - return packed, PAYLOAD_FILE - - else: - # Raw bytes - treat as file with no name - packed = ( - bytes([PAYLOAD_FILE]) + - struct.pack('>H', 0) + # No filename - struct.pack('>H', 0) + # No mime - content - ) - return packed, PAYLOAD_FILE - - -def _unpack_payload(data: bytes) -> DecodeResult: - """ - Unpack payload and extract content with metadata. - - Args: - data: Packed payload bytes - - Returns: - DecodeResult with appropriate content - """ - if len(data) < 1: - raise DecryptionError("Empty payload") - - payload_type = data[0] - - if payload_type == PAYLOAD_TEXT: - # Text message - text = data[1:].decode('utf-8') - return DecodeResult(payload_type='text', message=text) - - elif payload_type == PAYLOAD_FILE: - # File with metadata - offset = 1 - - # Read filename - filename_len = struct.unpack('>H', data[offset:offset+2])[0] - offset += 2 - filename = data[offset:offset+filename_len].decode('utf-8') if filename_len else None - offset += filename_len - - # Read mime type - mime_len = struct.unpack('>H', data[offset:offset+2])[0] - offset += 2 - mime_type = data[offset:offset+mime_len].decode('utf-8') if mime_len else None - offset += mime_len - - # Rest is file data - file_data = data[offset:] - - return DecodeResult( - payload_type='file', - file_data=file_data, - filename=filename, - mime_type=mime_type - ) - - else: - # Unknown type - try to decode as text (backward compatibility) - try: - text = data.decode('utf-8') - return DecodeResult(payload_type='text', message=text) - except UnicodeDecodeError: - return DecodeResult(payload_type='file', file_data=data) - - -def encrypt_message( - message: Union[str, bytes, FilePayload], - photo_data: bytes, - day_phrase: str, - date_str: str, - pin: str = "", - rsa_key_data: Optional[bytes] = None -) -> bytes: - """ - Encrypt message or file using AES-256-GCM with hybrid key derivation. - - Message format: - - Magic header (4 bytes) - - Version (1 byte) - - Date length (1 byte) - - Date string (variable) - - Salt (32 bytes) - - IV (12 bytes) - - Auth tag (16 bytes) - - Ciphertext (variable, padded) - - Args: - message: Message string, raw bytes, or FilePayload to encrypt - photo_data: Reference photo bytes - day_phrase: The day's phrase - date_str: Date string (YYYY-MM-DD) - pin: Optional static PIN - rsa_key_data: Optional RSA key bytes - - Returns: - Encrypted message bytes - - Raises: - EncryptionError: If encryption fails - """ - try: - salt = secrets.token_bytes(SALT_SIZE) - key = derive_hybrid_key(photo_data, day_phrase, date_str, salt, pin, rsa_key_data) - iv = secrets.token_bytes(IV_SIZE) - - # Pack payload with type marker - packed_payload, _ = _pack_payload(message) - - # Random padding to hide message length - padding_len = secrets.randbelow(256) + 64 - padded_len = ((len(packed_payload) + padding_len + 255) // 256) * 256 - padding_needed = padded_len - len(packed_payload) - padding = secrets.token_bytes(padding_needed - 4) + struct.pack('>I', len(packed_payload)) - padded_message = packed_payload + padding - - # Encrypt with AES-256-GCM - cipher = Cipher(algorithms.AES(key), modes.GCM(iv), backend=default_backend()) - encryptor = cipher.encryptor() - encryptor.authenticate_additional_data(MAGIC_HEADER + bytes([FORMAT_VERSION])) - ciphertext = encryptor.update(padded_message) + encryptor.finalize() - - date_bytes = date_str.encode() - - return ( - MAGIC_HEADER + - bytes([FORMAT_VERSION]) + - bytes([len(date_bytes)]) + - date_bytes + - salt + - iv + - encryptor.tag + - ciphertext - ) - - except Exception as e: - raise EncryptionError(f"Encryption failed: {e}") from e - - -def parse_header(encrypted_data: bytes) -> Optional[dict]: - """ - Parse the header from encrypted data. - - Args: - encrypted_data: Raw encrypted bytes - - Returns: - Dict with date, salt, iv, tag, ciphertext or None if invalid - """ - if len(encrypted_data) < 10 or encrypted_data[:4] != MAGIC_HEADER: - return None - - try: - version = encrypted_data[4] - if version != FORMAT_VERSION: - return None - - date_len = encrypted_data[5] - date_str = encrypted_data[6:6 + date_len].decode() - - offset = 6 + date_len - salt = encrypted_data[offset:offset + SALT_SIZE] - offset += SALT_SIZE - iv = encrypted_data[offset:offset + IV_SIZE] - offset += IV_SIZE - tag = encrypted_data[offset:offset + TAG_SIZE] - offset += TAG_SIZE - ciphertext = encrypted_data[offset:] - - return { - 'date': date_str, - 'salt': salt, - 'iv': iv, - 'tag': tag, - 'ciphertext': ciphertext - } - except Exception: - return None - - -def decrypt_message( - encrypted_data: bytes, - photo_data: bytes, - day_phrase: str, - pin: str = "", - rsa_key_data: Optional[bytes] = None -) -> DecodeResult: - """ - Decrypt message using the embedded date from the header. - - Args: - encrypted_data: Encrypted message bytes - photo_data: Reference photo bytes - day_phrase: The day's phrase (must match encoding day) - pin: Optional static PIN - rsa_key_data: Optional RSA key bytes - - Returns: - DecodeResult with decrypted content - - Raises: - InvalidHeaderError: If data doesn't have valid Stegasoo header - DecryptionError: If decryption fails (wrong credentials) - """ - header = parse_header(encrypted_data) - if not header: - raise InvalidHeaderError("Invalid or missing Stegasoo header") - - try: - key = derive_hybrid_key( - photo_data, day_phrase, header['date'], header['salt'], pin, rsa_key_data - ) - - cipher = Cipher( - algorithms.AES(key), - modes.GCM(header['iv'], header['tag']), - backend=default_backend() - ) - decryptor = cipher.decryptor() - decryptor.authenticate_additional_data(MAGIC_HEADER + bytes([FORMAT_VERSION])) - - padded_plaintext = decryptor.update(header['ciphertext']) + decryptor.finalize() - original_length = struct.unpack('>I', padded_plaintext[-4:])[0] - - payload_data = padded_plaintext[:original_length] - result = _unpack_payload(payload_data) - result.date_encoded = header['date'] - - return result - - except Exception as e: - raise DecryptionError( - "Decryption failed. Check your phrase, PIN, RSA key, and reference photo." - ) from e - - -def decrypt_message_text( - encrypted_data: bytes, - photo_data: bytes, - day_phrase: str, - pin: str = "", - rsa_key_data: Optional[bytes] = None -) -> str: - """ - Decrypt message and return as text string. - - For backward compatibility - returns text content or raises error for files. - - Args: - encrypted_data: Encrypted message bytes - photo_data: Reference photo bytes - day_phrase: The day's phrase - pin: Optional static PIN - rsa_key_data: Optional RSA key bytes - - Returns: - Decrypted message string - - Raises: - DecryptionError: If decryption fails or content is a file - """ - result = decrypt_message(encrypted_data, photo_data, day_phrase, pin, rsa_key_data) - - if result.is_file: - if result.file_data: - # Try to decode as text - try: - return result.file_data.decode('utf-8') - except UnicodeDecodeError: - raise DecryptionError( - f"Content is a binary file ({result.filename or 'unnamed'}), not text" - ) - return "" - - return result.message or "" - - -def get_date_from_encrypted(encrypted_data: bytes) -> Optional[str]: - """ - Extract the date string from encrypted data without decrypting. - - Useful for determining which day's phrase to use. - - Args: - encrypted_data: Encrypted message bytes - - Returns: - Date string (YYYY-MM-DD) or None if invalid - """ - header = parse_header(encrypted_data) - return header['date'] if header else None - - -def has_argon2() -> bool: - """Check if Argon2 is available.""" - return HAS_ARGON2 diff --git a/src_20251228/stegasoo/exceptions.py b/src_20251228/stegasoo/exceptions.py deleted file mode 100644 index 324e58c..0000000 --- a/src_20251228/stegasoo/exceptions.py +++ /dev/null @@ -1,150 +0,0 @@ -""" -Stegasoo Exceptions - -Custom exception classes for clear error handling across all frontends. -""" - - -class StegasooError(Exception): - """Base exception for all Stegasoo errors.""" - pass - - -# ============================================================================ -# VALIDATION ERRORS -# ============================================================================ - -class ValidationError(StegasooError): - """Base class for validation errors.""" - pass - - -class PinValidationError(ValidationError): - """PIN validation failed.""" - pass - - -class MessageValidationError(ValidationError): - """Message validation failed.""" - pass - - -class ImageValidationError(ValidationError): - """Image validation failed.""" - pass - - -class KeyValidationError(ValidationError): - """RSA key validation failed.""" - pass - - -class SecurityFactorError(ValidationError): - """Security factor requirements not met.""" - pass - - -# ============================================================================ -# CRYPTO ERRORS -# ============================================================================ - -class CryptoError(StegasooError): - """Base class for cryptographic errors.""" - pass - - -class EncryptionError(CryptoError): - """Encryption failed.""" - pass - - -class DecryptionError(CryptoError): - """Decryption failed (wrong key, corrupted data, etc.).""" - pass - - -class KeyDerivationError(CryptoError): - """Key derivation failed.""" - pass - - -class KeyGenerationError(CryptoError): - """Key generation failed.""" - pass - - -class KeyPasswordError(CryptoError): - """RSA key password is incorrect or missing.""" - pass - - -# ============================================================================ -# STEGANOGRAPHY ERRORS -# ============================================================================ - -class SteganographyError(StegasooError): - """Base class for steganography errors.""" - pass - - -class CapacityError(SteganographyError): - """Carrier image too small for message.""" - - def __init__(self, needed: int, available: int): - self.needed = needed - self.available = available - super().__init__( - f"Carrier image too small. Need {needed:,} bytes, have {available:,} bytes capacity." - ) - - -class ExtractionError(SteganographyError): - """Failed to extract hidden data from image.""" - pass - - -class EmbeddingError(SteganographyError): - """Failed to embed data in image.""" - pass - - -class InvalidHeaderError(SteganographyError): - """Invalid or missing Stegasoo header in extracted data.""" - pass - - -# ============================================================================ -# FILE ERRORS -# ============================================================================ - -class FileError(StegasooError): - """Base class for file-related errors.""" - pass - - -class FileNotFoundError(FileError): - """Required file not found.""" - pass - - -class FileTooLargeError(FileError): - """File exceeds size limit.""" - - def __init__(self, size: int, limit: int, filename: str = "File"): - self.size = size - self.limit = limit - self.filename = filename - super().__init__( - f"{filename} too large ({size:,} bytes). Maximum allowed: {limit:,} bytes." - ) - - -class UnsupportedFileTypeError(FileError): - """File type not supported.""" - - def __init__(self, extension: str, allowed: set[str]): - self.extension = extension - self.allowed = allowed - super().__init__( - f"Unsupported file type: .{extension}. Allowed: {', '.join(sorted(allowed))}" - ) diff --git a/src_20251228/stegasoo/keygen.py b/src_20251228/stegasoo/keygen.py deleted file mode 100644 index ec60f9a..0000000 --- a/src_20251228/stegasoo/keygen.py +++ /dev/null @@ -1,228 +0,0 @@ -""" -Stegasoo Key Generation - -Generate PINs, passphrases, and RSA keys. -""" - -import secrets -from typing import Optional - -from cryptography.hazmat.primitives.asymmetric import rsa -from cryptography.hazmat.primitives import serialization -from cryptography.hazmat.primitives.serialization import load_pem_private_key -from cryptography.hazmat.backends import default_backend - -from .constants import ( - DAY_NAMES, - MIN_PIN_LENGTH, MAX_PIN_LENGTH, DEFAULT_PIN_LENGTH, - MIN_PHRASE_WORDS, MAX_PHRASE_WORDS, DEFAULT_PHRASE_WORDS, - MIN_RSA_BITS, VALID_RSA_SIZES, DEFAULT_RSA_BITS, - get_wordlist, -) -from .models import Credentials, KeyInfo -from .exceptions import KeyGenerationError, KeyPasswordError - - -def generate_pin(length: int = DEFAULT_PIN_LENGTH) -> str: - """ - Generate a random PIN. - - PINs never start with zero for usability. - - Args: - length: PIN length (6-9 digits) - - Returns: - PIN string - """ - length = max(MIN_PIN_LENGTH, min(MAX_PIN_LENGTH, length)) - - # First digit: 1-9 (no leading zero) - first_digit = str(secrets.randbelow(9) + 1) - - # Remaining digits: 0-9 - rest = ''.join(str(secrets.randbelow(10)) for _ in range(length - 1)) - - return first_digit + rest - - -def generate_phrase(words_per_phrase: int = DEFAULT_PHRASE_WORDS) -> str: - """ - Generate a random passphrase from BIP-39 wordlist. - - Args: - words_per_phrase: Number of words (3-12) - - Returns: - Space-separated phrase - """ - words_per_phrase = max(MIN_PHRASE_WORDS, min(MAX_PHRASE_WORDS, words_per_phrase)) - wordlist = get_wordlist() - - words = [secrets.choice(wordlist) for _ in range(words_per_phrase)] - return ' '.join(words) - - -def generate_day_phrases(words_per_phrase: int = DEFAULT_PHRASE_WORDS) -> dict[str, str]: - """ - Generate phrases for all days of the week. - - Args: - words_per_phrase: Number of words per phrase (3-12) - - Returns: - Dict mapping day names to phrases - """ - return {day: generate_phrase(words_per_phrase) for day in DAY_NAMES} - - -def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey: - """ - Generate an RSA private key. - - Args: - bits: Key size (2048, 3072, or 4096) - - Returns: - RSA private key object - - Raises: - KeyGenerationError: If generation fails - """ - if bits not in VALID_RSA_SIZES: - bits = DEFAULT_RSA_BITS - - try: - return rsa.generate_private_key( - public_exponent=65537, - key_size=bits, - backend=default_backend() - ) - except Exception as e: - raise KeyGenerationError(f"Failed to generate RSA key: {e}") from e - - -def export_rsa_key_pem( - private_key: rsa.RSAPrivateKey, - password: Optional[str] = None -) -> bytes: - """ - Export RSA key to PEM format. - - Args: - private_key: RSA private key object - password: Optional password for encryption - - Returns: - PEM-encoded key bytes - """ - if password: - encryption = serialization.BestAvailableEncryption(password.encode()) - else: - encryption = serialization.NoEncryption() - - return private_key.private_bytes( - encoding=serialization.Encoding.PEM, - format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=encryption - ) - - -def load_rsa_key( - key_data: bytes, - password: Optional[str] = None -) -> rsa.RSAPrivateKey: - """ - Load RSA private key from PEM data. - - Args: - key_data: PEM-encoded key bytes - password: Password if key is encrypted - - Returns: - RSA private key object - - Raises: - KeyPasswordError: If password is wrong or missing - KeyGenerationError: If key is invalid - """ - try: - pwd_bytes = password.encode() if password else None - return load_pem_private_key(key_data, password=pwd_bytes, backend=default_backend()) - except TypeError: - raise KeyPasswordError("RSA key is password-protected. Please provide the password.") - except ValueError as e: - if "password" in str(e).lower() or "encrypted" in str(e).lower(): - raise KeyPasswordError("Incorrect password for RSA key.") - raise KeyGenerationError(f"Invalid RSA key: {e}") from e - except Exception as e: - raise KeyGenerationError(f"Could not load RSA key: {e}") from e - - -def get_key_info(key_data: bytes, password: Optional[str] = None) -> KeyInfo: - """ - Get information about an RSA key. - - Args: - key_data: PEM-encoded key bytes - password: Password if key is encrypted - - Returns: - KeyInfo with key size and encryption status - """ - # Check if encrypted - is_encrypted = b'ENCRYPTED' in key_data - - private_key = load_rsa_key(key_data, password) - - return KeyInfo( - key_size=private_key.key_size, - is_encrypted=is_encrypted, - pem_data=key_data - ) - - -def generate_credentials( - use_pin: bool = True, - use_rsa: bool = False, - pin_length: int = DEFAULT_PIN_LENGTH, - rsa_bits: int = DEFAULT_RSA_BITS, - words_per_phrase: int = DEFAULT_PHRASE_WORDS -) -> Credentials: - """ - Generate a complete set of credentials. - - At least one of use_pin or use_rsa must be True. - - Args: - use_pin: Whether to generate a PIN - use_rsa: Whether to generate an RSA key - pin_length: PIN length if generating - rsa_bits: RSA key size if generating - words_per_phrase: Words per daily phrase - - Returns: - Credentials object - - Raises: - ValueError: If neither PIN nor RSA is selected - """ - if not use_pin and not use_rsa: - raise ValueError("Must select at least one security factor (PIN or RSA key)") - - phrases = generate_day_phrases(words_per_phrase) - - pin = generate_pin(pin_length) if use_pin else None - - rsa_key_pem = None - if use_rsa: - private_key = generate_rsa_key(rsa_bits) - rsa_key_pem = export_rsa_key_pem(private_key).decode('utf-8') - - return Credentials( - phrases=phrases, - pin=pin, - rsa_key_pem=rsa_key_pem, - rsa_bits=rsa_bits if use_rsa else None, - words_per_phrase=words_per_phrase - ) diff --git a/src_20251228/stegasoo/models.py b/src_20251228/stegasoo/models.py deleted file mode 100644 index 747de21..0000000 --- a/src_20251228/stegasoo/models.py +++ /dev/null @@ -1,177 +0,0 @@ -""" -Stegasoo Data Models - -Dataclasses for structured data exchange between modules and frontends. -""" - -from dataclasses import dataclass, field -from datetime import date -from typing import Optional, Union - - -@dataclass -class Credentials: - """Generated credentials for encoding/decoding.""" - phrases: dict[str, str] # Day -> phrase mapping - pin: Optional[str] = None - rsa_key_pem: Optional[str] = None - rsa_bits: Optional[int] = None - words_per_phrase: int = 3 - - @property - def phrase_entropy(self) -> int: - """Entropy in bits from phrases (~11 bits per BIP-39 word).""" - return self.words_per_phrase * 11 - - @property - def pin_entropy(self) -> int: - """Entropy in bits from PIN (~3.32 bits per digit).""" - if self.pin: - return int(len(self.pin) * 3.32) - return 0 - - @property - def rsa_entropy(self) -> int: - """Effective entropy from RSA key.""" - if self.rsa_key_pem and self.rsa_bits: - return min(self.rsa_bits // 16, 128) - return 0 - - @property - def total_entropy(self) -> int: - """Total entropy in bits (excluding reference photo).""" - return self.phrase_entropy + self.pin_entropy + self.rsa_entropy - - -@dataclass -class FilePayload: - """Represents a file to be embedded.""" - data: bytes - filename: str - mime_type: Optional[str] = None - - @property - def size(self) -> int: - return len(self.data) - - @classmethod - def from_file(cls, filepath: str, filename: Optional[str] = None) -> 'FilePayload': - """Create FilePayload from a file path.""" - from pathlib import Path - import mimetypes - - path = Path(filepath) - data = path.read_bytes() - name = filename or path.name - mime, _ = mimetypes.guess_type(name) - - return cls(data=data, filename=name, mime_type=mime) - - -@dataclass -class EncodeInput: - """Input parameters for encoding a message.""" - message: Union[str, bytes, FilePayload] # Text, raw bytes, or file - reference_photo: bytes - carrier_image: bytes - day_phrase: str - pin: str = "" - rsa_key_data: Optional[bytes] = None - rsa_password: Optional[str] = None - date_str: Optional[str] = None # YYYY-MM-DD, defaults to today - - def __post_init__(self): - if self.date_str is None: - self.date_str = date.today().isoformat() - - -@dataclass -class EncodeResult: - """Result of encoding operation.""" - stego_image: bytes - filename: str - pixels_modified: int - total_pixels: int - capacity_used: float # 0.0 - 1.0 - date_used: str - - @property - def capacity_percent(self) -> float: - """Capacity used as percentage.""" - return self.capacity_used * 100 - - -@dataclass -class DecodeInput: - """Input parameters for decoding a message.""" - stego_image: bytes - reference_photo: bytes - day_phrase: str - pin: str = "" - rsa_key_data: Optional[bytes] = None - rsa_password: Optional[str] = None - - -@dataclass -class DecodeResult: - """Result of decoding operation.""" - payload_type: str # 'text' or 'file' - message: Optional[str] = None # For text payloads - file_data: Optional[bytes] = None # For file payloads - filename: Optional[str] = None # Original filename for file payloads - mime_type: Optional[str] = None # MIME type hint - date_encoded: Optional[str] = None - - @property - def is_file(self) -> bool: - return self.payload_type == 'file' - - @property - def is_text(self) -> bool: - return self.payload_type == 'text' - - def get_content(self) -> Union[str, bytes]: - """Get the decoded content (text or bytes).""" - if self.is_text: - return self.message or "" - return self.file_data or b"" - - -@dataclass -class EmbedStats: - """Statistics from image embedding.""" - pixels_modified: int - total_pixels: int - capacity_used: float - bytes_embedded: int - - @property - def modification_percent(self) -> float: - """Percentage of pixels modified.""" - return (self.pixels_modified / self.total_pixels) * 100 if self.total_pixels > 0 else 0 - - -@dataclass -class KeyInfo: - """Information about an RSA key.""" - key_size: int - is_encrypted: bool - pem_data: bytes - - -@dataclass -class ValidationResult: - """Result of input validation.""" - is_valid: bool - error_message: str = "" - details: dict = field(default_factory=dict) - - @classmethod - def ok(cls, **details) -> 'ValidationResult': - """Create a successful validation result.""" - return cls(is_valid=True, details=details) - - @classmethod - def error(cls, message: str, **details) -> 'ValidationResult': - """Create a failed validation result.""" - return cls(is_valid=False, error_message=message, details=details) diff --git a/src_20251228/stegasoo/qr_utils.py b/src_20251228/stegasoo/qr_utils.py deleted file mode 100644 index 13a7c01..0000000 --- a/src_20251228/stegasoo/qr_utils.py +++ /dev/null @@ -1,397 +0,0 @@ -""" -Stegasoo QR Code Utilities - -Functions for generating and reading QR codes containing RSA keys. -Supports automatic compression for large keys. - -IMPROVEMENTS IN THIS VERSION: -- Much more robust PEM normalization -- Better handling of QR code extraction edge cases -- Improved error messages -""" - -import io -import zlib -import base64 -from typing import Optional, Tuple - -from PIL import Image - -# QR code generation -try: - import qrcode - from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_M - HAS_QRCODE_WRITE = True -except ImportError: - HAS_QRCODE_WRITE = False - -# QR code reading -try: - from pyzbar.pyzbar import decode as pyzbar_decode - from pyzbar.pyzbar import ZBarSymbol - HAS_QRCODE_READ = True -except ImportError: - HAS_QRCODE_READ = False - - -# Constants -COMPRESSION_PREFIX = "STEGASOO-Z:" -QR_MAX_BINARY = 2900 # Safe limit for binary data in QR - - -def compress_data(data: str) -> str: - """ - Compress string data for QR code storage. - - Args: - data: String to compress - - Returns: - Compressed string with STEGASOO-Z: prefix - """ - compressed = zlib.compress(data.encode('utf-8'), level=9) - encoded = base64.b64encode(compressed).decode('ascii') - return COMPRESSION_PREFIX + encoded - - -def decompress_data(data: str) -> str: - """ - Decompress data from QR code. - - Args: - data: Compressed string with STEGASOO-Z: prefix - - Returns: - Original uncompressed string - - Raises: - ValueError: If data is not valid compressed format - """ - if not data.startswith(COMPRESSION_PREFIX): - raise ValueError("Data is not in compressed format") - - encoded = data[len(COMPRESSION_PREFIX):] - compressed = base64.b64decode(encoded) - return zlib.decompress(compressed).decode('utf-8') - - -def normalize_pem(pem_data: str) -> str: - """ - Normalize PEM data to ensure proper formatting for cryptography library. - - The cryptography library is very particular about PEM formatting. - This function handles all common issues from QR code extraction: - - Inconsistent line endings (CRLF, LF, CR) - - Missing newlines after header/before footer - - Extra whitespace, tabs, multiple spaces - - Non-ASCII characters - - Incorrect base64 padding - - Malformed headers/footers - - Args: - pem_data: Raw PEM string from QR code - - Returns: - Properly formatted PEM string that cryptography library will accept - """ - import re - - # Step 1: Normalize ALL line endings to \n - pem_data = pem_data.replace('\r\n', '\n').replace('\r', '\n') - - # Step 2: Remove leading/trailing whitespace - pem_data = pem_data.strip() - - # Step 3: Remove any non-ASCII characters (QR artifacts) - pem_data = ''.join(char for char in pem_data if ord(char) < 128) - - # Step 4: Extract header, content, and footer with flexible regex - # This handles variations like: - # - "PRIVATE KEY" vs "RSA PRIVATE KEY" - # - Extra spaces in headers - # - Missing spaces - pattern = r'(-----BEGIN[^-]*-----)(.*?)(-----END[^-]*-----)' - match = re.search(pattern, pem_data, re.DOTALL | re.IGNORECASE) - - if not match: - # Fallback: try even more permissive pattern - pattern = r'(-+BEGIN[^-]+-+)(.*?)(-+END[^-]+-+)' - match = re.search(pattern, pem_data, re.DOTALL | re.IGNORECASE) - - if not match: - # Last resort: return original if can't parse - return pem_data - - header_raw = match.group(1).strip() - content_raw = match.group(2) - footer_raw = match.group(3).strip() - - # Step 5: Normalize header and footer - # Standardize spacing and ensure proper format - header = re.sub(r'\s+', ' ', header_raw) - footer = re.sub(r'\s+', ' ', footer_raw) - - # Ensure exactly 5 dashes on each side - header = re.sub(r'^-+', '-----', header) - header = re.sub(r'-+$', '-----', header) - footer = re.sub(r'^-+', '-----', footer) - footer = re.sub(r'-+$', '-----', footer) - - # Step 6: Clean the base64 content THOROUGHLY - # Remove ALL whitespace: spaces, tabs, newlines - # Keep only valid base64 characters: A-Z, a-z, 0-9, +, /, = - content_clean = ''.join( - char for char in content_raw - if char.isalnum() or char in '+/=' - ) - - # Double-check: remove any remaining invalid characters - content_clean = re.sub(r'[^A-Za-z0-9+/=]', '', content_clean) - - # Step 7: Fix base64 padding - # Base64 strings must be divisible by 4 - remainder = len(content_clean) % 4 - if remainder: - content_clean += '=' * (4 - remainder) - - # Step 8: Split into 64-character lines (PEM standard) - lines = [content_clean[i:i+64] for i in range(0, len(content_clean), 64)] - - # Step 9: Reconstruct with EXACT PEM formatting - # Format: header\ncontent_line1\ncontent_line2\n...\nfooter\n - return header + '\n' + '\n'.join(lines) + '\n' + footer + '\n' - - -def is_compressed(data: str) -> bool: - """Check if data has compression prefix.""" - return data.startswith(COMPRESSION_PREFIX) - - -def auto_decompress(data: str) -> str: - """ - Automatically decompress data if compressed, otherwise return as-is. - - Args: - data: Possibly compressed string - - Returns: - Decompressed string - """ - if is_compressed(data): - return decompress_data(data) - return data - - -def get_compressed_size(data: str) -> int: - """Get size of data after compression (including prefix).""" - return len(compress_data(data)) - - -def can_fit_in_qr(data: str, compress: bool = False) -> bool: - """ - Check if data can fit in a QR code. - - Args: - data: String data - compress: Whether compression will be used - - Returns: - True if data fits - """ - if compress: - size = get_compressed_size(data) - else: - size = len(data.encode('utf-8')) - return size <= QR_MAX_BINARY - - -def needs_compression(data: str) -> bool: - """Check if data needs compression to fit in QR code.""" - return not can_fit_in_qr(data, compress=False) and can_fit_in_qr(data, compress=True) - - -def generate_qr_code( - data: str, - compress: bool = False, - error_correction=None -) -> bytes: - """ - Generate a QR code PNG from string data. - - Args: - data: String data to encode - compress: Whether to compress data first - error_correction: QR error correction level (default: auto) - - Returns: - PNG image bytes - - Raises: - RuntimeError: If qrcode library not available - ValueError: If data too large for QR code - """ - if not HAS_QRCODE_WRITE: - raise RuntimeError("qrcode library not installed. Run: pip install qrcode[pil]") - - qr_data = data - - # Compress if requested - if compress: - qr_data = compress_data(data) - - # Check size - if len(qr_data.encode('utf-8')) > QR_MAX_BINARY: - raise ValueError( - f"Data too large for QR code ({len(qr_data)} bytes). " - f"Maximum: {QR_MAX_BINARY} bytes" - ) - - # Use lower error correction for larger data - if error_correction is None: - error_correction = ERROR_CORRECT_L if len(qr_data) > 1000 else ERROR_CORRECT_M - - qr = qrcode.QRCode( - version=None, - error_correction=error_correction, - box_size=10, - border=4, - ) - qr.add_data(qr_data) - qr.make(fit=True) - - img = qr.make_image(fill_color="black", back_color="white") - - buf = io.BytesIO() - img.save(buf, format='PNG') - buf.seek(0) - return buf.getvalue() - - -def read_qr_code(image_data: bytes) -> Optional[str]: - """ - Read QR code from image data. - - Args: - image_data: Image bytes (PNG, JPG, etc.) - - Returns: - Decoded string, or None if no QR code found - - Raises: - RuntimeError: If pyzbar library not available - """ - if not HAS_QRCODE_READ: - raise RuntimeError( - "pyzbar library not installed. Run: pip install pyzbar\n" - "Also requires system library: sudo apt-get install libzbar0" - ) - - try: - img = Image.open(io.BytesIO(image_data)) - - # Convert to RGB if necessary (pyzbar works best with RGB/grayscale) - if img.mode not in ('RGB', 'L'): - img = img.convert('RGB') - - # Decode QR codes - decoded = pyzbar_decode(img, symbols=[ZBarSymbol.QRCODE]) - - if not decoded: - return None - - # Return first QR code found - return decoded[0].data.decode('utf-8') - - except Exception: - return None - - -def read_qr_code_from_file(filepath: str) -> Optional[str]: - """ - Read QR code from image file. - - Args: - filepath: Path to image file - - Returns: - Decoded string, or None if no QR code found - """ - with open(filepath, 'rb') as f: - return read_qr_code(f.read()) - - -def extract_key_from_qr(image_data: bytes) -> Optional[str]: - """ - Extract RSA key from QR code image, auto-decompressing if needed. - - This function is more robust than the original, with better error handling - and PEM normalization. - - Args: - image_data: Image bytes containing QR code - - Returns: - PEM-encoded RSA key string, or None if not found/invalid - """ - # Step 1: Read QR code - qr_data = read_qr_code(image_data) - - if not qr_data: - return None - - # Step 2: Auto-decompress if needed - try: - if is_compressed(qr_data): - key_pem = decompress_data(qr_data) - else: - key_pem = qr_data - except Exception as e: - # If decompression fails, try using data as-is - key_pem = qr_data - - # Step 3: Validate it looks like a PEM key - if '-----BEGIN' not in key_pem or '-----END' not in key_pem: - return None - - # Step 4: Aggressively normalize PEM format - # This is crucial - QR codes can introduce subtle formatting issues - try: - key_pem = normalize_pem(key_pem) - except Exception as e: - # If normalization fails, return None rather than broken PEM - return None - - # Step 5: Final validation - ensure it still looks like PEM - if '-----BEGIN' in key_pem and '-----END' in key_pem: - return key_pem - - return None - - -def extract_key_from_qr_file(filepath: str) -> Optional[str]: - """ - Extract RSA key from QR code image file. - - Args: - filepath: Path to image file containing QR code - - Returns: - PEM-encoded RSA key string, or None if not found/invalid - """ - with open(filepath, 'rb') as f: - return extract_key_from_qr(f.read()) - - -def has_qr_write() -> bool: - """Check if QR code writing is available.""" - return HAS_QRCODE_WRITE - - -def has_qr_read() -> bool: - """Check if QR code reading is available.""" - return HAS_QRCODE_READ - - -def has_qr_support() -> bool: - """Check if full QR code support is available.""" - return HAS_QRCODE_WRITE and HAS_QRCODE_READ diff --git a/src_20251228/stegasoo/steganography.py b/src_20251228/stegasoo/steganography.py deleted file mode 100644 index d9f5f12..0000000 --- a/src_20251228/stegasoo/steganography.py +++ /dev/null @@ -1,350 +0,0 @@ -""" -Stegasoo Steganography Functions - -LSB embedding and extraction with pseudo-random pixel selection. -""" - -import io -import struct -from typing import Optional - -from PIL import Image -from cryptography.hazmat.primitives.ciphers import Cipher, algorithms -from cryptography.hazmat.backends import default_backend - -from .models import EmbedStats -from .exceptions import CapacityError, ExtractionError, EmbeddingError - - -# Lossless formats that preserve LSB data -LOSSLESS_FORMATS = {'PNG', 'BMP', 'TIFF'} - -# Format to extension mapping -FORMAT_TO_EXT = { - 'PNG': 'png', - 'BMP': 'bmp', - 'TIFF': 'tiff', -} - -# Extension to PIL format mapping -EXT_TO_FORMAT = { - 'png': 'PNG', - 'bmp': 'BMP', - 'tiff': 'TIFF', - 'tif': 'TIFF', -} - - -def get_output_format(input_format: Optional[str]) -> tuple[str, str]: - """ - Determine the output format based on input format. - - Args: - input_format: PIL format string of input image (e.g., 'JPEG', 'PNG') - - Returns: - Tuple of (PIL format string, file extension) for output - Falls back to PNG for lossy or unknown formats. - """ - if input_format and input_format.upper() in LOSSLESS_FORMATS: - fmt = input_format.upper() - return fmt, FORMAT_TO_EXT.get(fmt, 'png') - # Default to PNG for lossy formats (JPEG, GIF) or unknown - return 'PNG', 'png' - - -def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list[int]: - """ - Generate pseudo-random pixel indices for embedding. - - Uses ChaCha20 as a CSPRNG seeded by the key to deterministically - select which pixels will hold hidden data. - - Args: - key: 32-byte key for pixel selection - num_pixels: Total pixels in image - num_needed: Number of pixels needed for embedding - - Returns: - List of pixel indices - """ - if num_needed >= num_pixels // 2: - # If we need many pixels, shuffle all indices - nonce = b'\x00' * 16 - cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend()) - encryptor = cipher.encryptor() - - indices = list(range(num_pixels)) - random_bytes = encryptor.update(b'\x00' * (num_pixels * 4)) - - for i in range(num_pixels - 1, 0, -1): - j_bytes = random_bytes[(num_pixels - 1 - i) * 4:(num_pixels - i) * 4] - j = int.from_bytes(j_bytes, 'big') % (i + 1) - indices[i], indices[j] = indices[j], indices[i] - - return indices[:num_needed] - - # Optimized path: generate indices directly - selected = [] - used = set() - - nonce = b'\x00' * 16 - cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend()) - encryptor = cipher.encryptor() - - # Generate more than needed to handle collisions - bytes_needed = (num_needed * 2) * 4 - random_bytes = encryptor.update(b'\x00' * bytes_needed) - - byte_offset = 0 - while len(selected) < num_needed and byte_offset < len(random_bytes) - 4: - idx = int.from_bytes(random_bytes[byte_offset:byte_offset + 4], 'big') % num_pixels - byte_offset += 4 - - if idx not in used: - used.add(idx) - selected.append(idx) - - # Generate additional if needed (rare) - while len(selected) < num_needed: - extra_bytes = encryptor.update(b'\x00' * 4) - idx = int.from_bytes(extra_bytes, 'big') % num_pixels - if idx not in used: - used.add(idx) - selected.append(idx) - - return selected - - -def embed_in_image( - carrier_data: bytes, - encrypted_data: bytes, - pixel_key: bytes, - bits_per_channel: int = 1, - output_format: Optional[str] = None -) -> tuple[bytes, EmbedStats, str]: - """ - Embed encrypted data in carrier image using LSB steganography. - - Uses pseudo-random pixel selection based on pixel_key to scatter - the data across the image, defeating statistical analysis. - - Args: - carrier_data: Carrier image bytes - encrypted_data: Data to embed - pixel_key: Key for pixel selection - bits_per_channel: Bits to use per color channel (1-2) - output_format: Force specific output format (PNG, BMP). - If None, auto-detect from carrier (lossless) or default to PNG. - - Returns: - Tuple of (image bytes, EmbedStats, file extension) - - Raises: - CapacityError: If carrier is too small - EmbeddingError: If embedding fails - """ - try: - img = Image.open(io.BytesIO(carrier_data)) - input_format = img.format - - if img.mode != 'RGB': - img = img.convert('RGB') - - pixels = list(img.getdata()) - num_pixels = len(pixels) - - bits_per_pixel = 3 * bits_per_channel - max_bytes = (num_pixels * bits_per_pixel) // 8 - - # Prepend length - data_with_len = struct.pack('>I', len(encrypted_data)) + encrypted_data - - if len(data_with_len) > max_bytes: - raise CapacityError(len(data_with_len), max_bytes) - - # Convert to binary string - binary_data = ''.join(format(b, '08b') for b in data_with_len) - pixels_needed = (len(binary_data) + bits_per_pixel - 1) // bits_per_pixel - - # Get pixel indices - selected_indices = generate_pixel_indices(pixel_key, num_pixels, pixels_needed) - - # Embed data - new_pixels = list(pixels) - clear_mask = 0xFF ^ ((1 << bits_per_channel) - 1) - - bit_idx = 0 - for pixel_idx in selected_indices: - if bit_idx >= len(binary_data): - break - - r, g, b = new_pixels[pixel_idx] - - for channel_idx, channel_val in enumerate([r, g, b]): - if bit_idx >= len(binary_data): - break - bits = binary_data[bit_idx:bit_idx + bits_per_channel].ljust(bits_per_channel, '0') - new_val = (channel_val & clear_mask) | int(bits, 2) - - if channel_idx == 0: - r = new_val - elif channel_idx == 1: - g = new_val - else: - b = new_val - - bit_idx += bits_per_channel - - new_pixels[pixel_idx] = (r, g, b) - - # Create output image - stego_img = Image.new('RGB', img.size) - stego_img.putdata(new_pixels) - - # Determine output format - if output_format: - out_fmt = output_format.upper() - out_ext = FORMAT_TO_EXT.get(out_fmt, 'png') - else: - out_fmt, out_ext = get_output_format(input_format) - - output = io.BytesIO() - stego_img.save(output, out_fmt) - output.seek(0) - - stats = EmbedStats( - pixels_modified=len(selected_indices), - total_pixels=num_pixels, - capacity_used=len(data_with_len) / max_bytes, - bytes_embedded=len(data_with_len) - ) - - return output.getvalue(), stats, out_ext - - except CapacityError: - raise - except Exception as e: - raise EmbeddingError(f"Failed to embed data: {e}") from e - - -def extract_from_image( - image_data: bytes, - pixel_key: bytes, - bits_per_channel: int = 1 -) -> Optional[bytes]: - """ - Extract hidden data from a stego image. - - Args: - image_data: Stego image bytes - pixel_key: Key for pixel selection (must match encoding) - bits_per_channel: Bits per channel (must match encoding) - - Returns: - Extracted data bytes, or None if extraction fails - - Raises: - ExtractionError: If extraction fails critically - """ - try: - img = Image.open(io.BytesIO(image_data)) - if img.mode != 'RGB': - img = img.convert('RGB') - - pixels = list(img.getdata()) - num_pixels = len(pixels) - bits_per_pixel = 3 * bits_per_channel - - # First, extract enough to get the length (4 bytes = 32 bits) - initial_pixels = (32 + bits_per_pixel - 1) // bits_per_pixel + 10 - initial_indices = generate_pixel_indices(pixel_key, num_pixels, initial_pixels) - - binary_data = '' - for pixel_idx in initial_indices: - r, g, b = pixels[pixel_idx] - for channel in [r, g, b]: - for bit_pos in range(bits_per_channel - 1, -1, -1): - binary_data += str((channel >> bit_pos) & 1) - - # Parse length - try: - length_bits = binary_data[:32] - data_length = struct.unpack('>I', int(length_bits, 2).to_bytes(4, 'big'))[0] - except Exception: - return None - - # Sanity check - max_possible = (num_pixels * bits_per_pixel) // 8 - 4 - if data_length > max_possible or data_length < 10: - return None - - # Extract full data - total_bits = (4 + data_length) * 8 - pixels_needed = (total_bits + bits_per_pixel - 1) // bits_per_pixel - - selected_indices = generate_pixel_indices(pixel_key, num_pixels, pixels_needed) - - binary_data = '' - for pixel_idx in selected_indices: - r, g, b = pixels[pixel_idx] - for channel in [r, g, b]: - for bit_pos in range(bits_per_channel - 1, -1, -1): - binary_data += str((channel >> bit_pos) & 1) - - data_bits = binary_data[32:32 + (data_length * 8)] - - data_bytes = bytearray() - for i in range(0, len(data_bits), 8): - byte_bits = data_bits[i:i + 8] - if len(byte_bits) == 8: - data_bytes.append(int(byte_bits, 2)) - - return bytes(data_bytes) - - except Exception as e: - raise ExtractionError(f"Failed to extract data: {e}") from e - - -def calculate_capacity(image_data: bytes, bits_per_channel: int = 1) -> int: - """ - Calculate the maximum message capacity of an image. - - Args: - image_data: Image bytes - bits_per_channel: Bits to use per color channel - - Returns: - Maximum bytes that can be embedded (minus overhead) - """ - img = Image.open(io.BytesIO(image_data)) - if img.mode != 'RGB': - img = img.convert('RGB') - - num_pixels = img.size[0] * img.size[1] - bits_per_pixel = 3 * bits_per_channel - max_bytes = (num_pixels * bits_per_pixel) // 8 - - # Subtract overhead: 4 bytes length + ~100 bytes header - return max(0, max_bytes - 104) - - -def get_image_dimensions(image_data: bytes) -> tuple[int, int]: - """Get image dimensions without loading full image.""" - img = Image.open(io.BytesIO(image_data)) - return img.size - - -def get_image_format(image_data: bytes) -> Optional[str]: - """Get image format (PIL format string like 'PNG', 'JPEG').""" - try: - img = Image.open(io.BytesIO(image_data)) - return img.format - except Exception: - return None - - -def is_lossless_format(image_data: bytes) -> bool: - """Check if image is in a lossless format suitable for steganography.""" - fmt = get_image_format(image_data) - return fmt is not None and fmt.upper() in LOSSLESS_FORMATS diff --git a/src_20251228/stegasoo/utils.py b/src_20251228/stegasoo/utils.py deleted file mode 100644 index 9c4c7a2..0000000 --- a/src_20251228/stegasoo/utils.py +++ /dev/null @@ -1,209 +0,0 @@ -""" -Stegasoo Utilities - -Secure deletion, filename generation, and other helpers. -""" - -import os -import random -import secrets -import shutil -from datetime import date, datetime -from pathlib import Path -from typing import Optional - -from .constants import DAY_NAMES - - -def generate_filename( - date_str: Optional[str] = None, - prefix: str = "", - extension: str = "png" -) -> str: - """ - Generate a filename for stego images. - - Format: {prefix}{random}_{YYYYMMDD}.{extension} - - Args: - date_str: Date string (YYYY-MM-DD), defaults to today - prefix: Optional prefix - extension: File extension without dot (default: 'png') - - Returns: - Filename string - """ - if date_str is None: - date_str = date.today().isoformat() - - date_compact = date_str.replace('-', '') - random_hex = secrets.token_hex(4) - - # Ensure extension doesn't have a leading dot - extension = extension.lstrip('.') - - return f"{prefix}{random_hex}_{date_compact}.{extension}" - - -def parse_date_from_filename(filename: str) -> Optional[str]: - """ - Extract date from a stego filename. - - Looks for patterns like _20251227 or _2025-12-27 - - Args: - filename: Filename to parse - - Returns: - Date string (YYYY-MM-DD) or None - """ - import re - - # Try YYYYMMDD format - match = re.search(r'_(\d{4})(\d{2})(\d{2})(?:\.|$)', filename) - if match: - year, month, day = match.groups() - return f"{year}-{month}-{day}" - - # Try YYYY-MM-DD format - match = re.search(r'_(\d{4})-(\d{2})-(\d{2})(?:\.|$)', filename) - if match: - year, month, day = match.groups() - return f"{year}-{month}-{day}" - - return None - - -def get_day_from_date(date_str: str) -> str: - """ - Get day of week name from date string. - - Args: - date_str: Date string (YYYY-MM-DD) - - Returns: - Day name (e.g., "Monday") - """ - try: - year, month, day = map(int, date_str.split('-')) - d = date(year, month, day) - return DAY_NAMES[d.weekday()] - except Exception: - return "" - - -def get_today_date() -> str: - """Get today's date as YYYY-MM-DD.""" - return date.today().isoformat() - - -def get_today_day() -> str: - """Get today's day name.""" - return DAY_NAMES[date.today().weekday()] - - -class SecureDeleter: - """ - Securely delete files by overwriting with random data. - - Implements multi-pass overwriting before deletion. - """ - - def __init__(self, path: str | Path, passes: int = 7): - """ - Initialize secure deleter. - - Args: - path: Path to file or directory - passes: Number of overwrite passes - """ - self.path = Path(path) - self.passes = passes - - def _overwrite_file(self, file_path: Path) -> None: - """Overwrite file with random data multiple times.""" - if not file_path.exists() or not file_path.is_file(): - return - - length = file_path.stat().st_size - if length == 0: - return - - patterns = [b'\x00', b'\xFF', bytes([random.randint(0, 255)])] - - for _ in range(self.passes): - with open(file_path, 'r+b') as f: - for pattern in patterns: - f.seek(0) - for _ in range(length): - f.write(pattern) - - # Final pass with random data - f.seek(0) - f.write(os.urandom(length)) - - def delete_file(self) -> None: - """Securely delete a single file.""" - if self.path.is_file(): - self._overwrite_file(self.path) - self.path.unlink() - - def delete_directory(self) -> None: - """Securely delete a directory and all contents.""" - if not self.path.is_dir(): - return - - # First, securely overwrite all files - for file_path in self.path.rglob('*'): - if file_path.is_file(): - self._overwrite_file(file_path) - - # Then remove the directory tree - shutil.rmtree(self.path) - - def execute(self) -> None: - """Securely delete the path (file or directory).""" - if self.path.is_file(): - self.delete_file() - elif self.path.is_dir(): - self.delete_directory() - - -def secure_delete(path: str | Path, passes: int = 7) -> None: - """ - Convenience function for secure deletion. - - Args: - path: Path to file or directory - passes: Number of overwrite passes - """ - SecureDeleter(path, passes).execute() - - -def format_file_size(size_bytes: int) -> str: - """ - Format file size for display. - - Args: - size_bytes: Size in bytes - - Returns: - Human-readable string (e.g., "1.5 MB") - """ - for unit in ['B', 'KB', 'MB', 'GB']: - if size_bytes < 1024: - if unit == 'B': - return f"{size_bytes} {unit}" - return f"{size_bytes:.1f} {unit}" - size_bytes /= 1024 - return f"{size_bytes:.1f} TB" - - -def format_number(n: int) -> str: - """Format number with commas.""" - return f"{n:,}" - - -def clamp(value: int, min_val: int, max_val: int) -> int: - """Clamp value to range.""" - return max(min_val, min(max_val, value)) diff --git a/src_20251228/stegasoo/validation.py b/src_20251228/stegasoo/validation.py deleted file mode 100644 index 9917d77..0000000 --- a/src_20251228/stegasoo/validation.py +++ /dev/null @@ -1,426 +0,0 @@ -""" -Stegasoo Input Validation - -Validators for all user inputs with clear error messages. -""" - -import io -from typing import Optional, Union - -from PIL import Image - -from .constants import ( - MIN_PIN_LENGTH, MAX_PIN_LENGTH, - MAX_MESSAGE_SIZE, MAX_FILE_PAYLOAD_SIZE, MAX_IMAGE_PIXELS, MAX_FILE_SIZE, - MIN_RSA_BITS, MIN_KEY_PASSWORD_LENGTH, - ALLOWED_IMAGE_EXTENSIONS, ALLOWED_KEY_EXTENSIONS, -) -from .models import ValidationResult, FilePayload -from .exceptions import ( - ValidationError, PinValidationError, MessageValidationError, - ImageValidationError, KeyValidationError, SecurityFactorError, - FileTooLargeError, UnsupportedFileTypeError, -) -from .keygen import load_rsa_key - - -def validate_pin(pin: str, required: bool = False) -> ValidationResult: - """ - Validate PIN format. - - Rules: - - 6-9 digits only - - Cannot start with zero - - Empty is OK if not required - - Args: - pin: PIN string to validate - required: Whether PIN is required - - Returns: - ValidationResult - """ - if not pin: - if required: - return ValidationResult.error("PIN is required") - return ValidationResult.ok() - - if not pin.isdigit(): - return ValidationResult.error("PIN must contain only digits") - - if len(pin) < MIN_PIN_LENGTH or len(pin) > MAX_PIN_LENGTH: - return ValidationResult.error( - f"PIN must be {MIN_PIN_LENGTH}-{MAX_PIN_LENGTH} digits" - ) - - if pin[0] == '0': - return ValidationResult.error("PIN cannot start with zero") - - return ValidationResult.ok(length=len(pin)) - - -def validate_message(message: str) -> ValidationResult: - """ - Validate text message content and size. - - Args: - message: Message text - - Returns: - ValidationResult - """ - if not message: - return ValidationResult.error("Message is required") - - if len(message) > MAX_MESSAGE_SIZE: - return ValidationResult.error( - f"Message too long ({len(message):,} chars). Maximum: {MAX_MESSAGE_SIZE:,} characters" - ) - - return ValidationResult.ok(length=len(message)) - - -def validate_payload(payload: Union[str, bytes, FilePayload]) -> ValidationResult: - """ - Validate a payload (text message, bytes, or file). - - Args: - payload: Text string, raw bytes, or FilePayload - - Returns: - ValidationResult - """ - if isinstance(payload, str): - return validate_message(payload) - - elif isinstance(payload, FilePayload): - if not payload.data: - return ValidationResult.error("File is empty") - - if len(payload.data) > MAX_FILE_PAYLOAD_SIZE: - return ValidationResult.error( - f"File too large ({len(payload.data):,} bytes). " - f"Maximum: {MAX_FILE_PAYLOAD_SIZE:,} bytes ({MAX_FILE_PAYLOAD_SIZE // 1024} KB)" - ) - - return ValidationResult.ok( - size=len(payload.data), - filename=payload.filename, - mime_type=payload.mime_type - ) - - elif isinstance(payload, bytes): - if not payload: - return ValidationResult.error("Payload is empty") - - if len(payload) > MAX_FILE_PAYLOAD_SIZE: - return ValidationResult.error( - f"Payload too large ({len(payload):,} bytes). " - f"Maximum: {MAX_FILE_PAYLOAD_SIZE:,} bytes ({MAX_FILE_PAYLOAD_SIZE // 1024} KB)" - ) - - return ValidationResult.ok(size=len(payload)) - - else: - return ValidationResult.error(f"Invalid payload type: {type(payload)}") - - -def validate_file_payload( - file_data: bytes, - filename: str = "", - max_size: int = MAX_FILE_PAYLOAD_SIZE -) -> ValidationResult: - """ - Validate a file for embedding. - - Args: - file_data: Raw file bytes - filename: Original filename (for display in errors) - max_size: Maximum allowed size in bytes - - Returns: - ValidationResult - """ - if not file_data: - return ValidationResult.error("File is empty") - - if len(file_data) > max_size: - size_kb = len(file_data) / 1024 - max_kb = max_size / 1024 - return ValidationResult.error( - f"File '{filename or 'unnamed'}' too large ({size_kb:.1f} KB). " - f"Maximum: {max_kb:.0f} KB" - ) - - return ValidationResult.ok(size=len(file_data), filename=filename) - - -def validate_image( - image_data: bytes, - name: str = "Image", - check_size: bool = True -) -> ValidationResult: - """ - Validate image data and dimensions. - - Args: - image_data: Raw image bytes - name: Name for error messages - check_size: Whether to check pixel dimensions - - Returns: - ValidationResult with width, height, pixels - """ - if not image_data: - return ValidationResult.error(f"{name} is required") - - if len(image_data) > MAX_FILE_SIZE: - return ValidationResult.error( - f"{name} too large ({len(image_data):,} bytes). Maximum: {MAX_FILE_SIZE:,} bytes" - ) - - try: - img = Image.open(io.BytesIO(image_data)) - width, height = img.size - num_pixels = width * height - - if check_size and num_pixels > MAX_IMAGE_PIXELS: - max_dim = int(MAX_IMAGE_PIXELS ** 0.5) - return ValidationResult.error( - f"{name} too large ({width}×{height} = {num_pixels:,} pixels). " - f"Maximum: ~{MAX_IMAGE_PIXELS:,} pixels ({max_dim}×{max_dim})" - ) - - return ValidationResult.ok( - width=width, - height=height, - pixels=num_pixels, - mode=img.mode, - format=img.format - ) - - except Exception as e: - return ValidationResult.error(f"Could not read {name}: {e}") - - -def validate_rsa_key( - key_data: bytes, - password: Optional[str] = None, - required: bool = False -) -> ValidationResult: - """ - Validate RSA private key. - - Args: - key_data: PEM-encoded key bytes - password: Password if key is encrypted - required: Whether key is required - - Returns: - ValidationResult with key_size - """ - if not key_data: - if required: - return ValidationResult.error("RSA key is required") - return ValidationResult.ok() - - try: - private_key = load_rsa_key(key_data, password) - key_size = private_key.key_size - - if key_size < MIN_RSA_BITS: - return ValidationResult.error( - f"RSA key must be at least {MIN_RSA_BITS} bits (got {key_size})" - ) - - return ValidationResult.ok(key_size=key_size) - - except Exception as e: - return ValidationResult.error(str(e)) - - -def validate_security_factors( - pin: str, - rsa_key_data: Optional[bytes] -) -> ValidationResult: - """ - Validate that at least one security factor is provided. - - Args: - pin: PIN string (may be empty) - rsa_key_data: RSA key bytes (may be None/empty) - - Returns: - ValidationResult - """ - has_pin = bool(pin and pin.strip()) - has_key = bool(rsa_key_data and len(rsa_key_data) > 0) - - if not has_pin and not has_key: - return ValidationResult.error( - "You must provide at least a PIN or RSA Key" - ) - - return ValidationResult.ok(has_pin=has_pin, has_key=has_key) - - -def validate_file_extension( - filename: str, - allowed: set[str], - file_type: str = "File" -) -> ValidationResult: - """ - Validate file extension. - - Args: - filename: Filename to check - allowed: Set of allowed extensions (lowercase, no dot) - file_type: Name for error messages - - Returns: - ValidationResult with extension - """ - if not filename or '.' not in filename: - return ValidationResult.error(f"{file_type} must have a file extension") - - ext = filename.rsplit('.', 1)[1].lower() - - if ext not in allowed: - return ValidationResult.error( - f"Unsupported {file_type.lower()} type: .{ext}. " - f"Allowed: {', '.join(sorted('.' + e for e in allowed))}" - ) - - return ValidationResult.ok(extension=ext) - - -def validate_image_file(filename: str) -> ValidationResult: - """Validate image file extension.""" - return validate_file_extension(filename, ALLOWED_IMAGE_EXTENSIONS, "Image") - - -def validate_key_file(filename: str) -> ValidationResult: - """Validate key file extension.""" - return validate_file_extension(filename, ALLOWED_KEY_EXTENSIONS, "Key file") - - -def validate_key_password(password: str) -> ValidationResult: - """ - Validate password for key encryption. - - Args: - password: Password string - - Returns: - ValidationResult - """ - if not password: - return ValidationResult.error("Password is required") - - if len(password) < MIN_KEY_PASSWORD_LENGTH: - return ValidationResult.error( - f"Password must be at least {MIN_KEY_PASSWORD_LENGTH} characters" - ) - - return ValidationResult.ok(length=len(password)) - - -def validate_phrase(phrase: str) -> ValidationResult: - """ - Validate day phrase. - - Args: - phrase: Phrase string - - Returns: - ValidationResult with word_count - """ - if not phrase or not phrase.strip(): - return ValidationResult.error("Day phrase is required") - - words = phrase.strip().split() - - return ValidationResult.ok(word_count=len(words)) - - -def validate_date_string(date_str: str) -> ValidationResult: - """ - Validate date string format (YYYY-MM-DD). - - Args: - date_str: Date string - - Returns: - ValidationResult - """ - if not date_str: - return ValidationResult.error("Date is required") - - if len(date_str) != 10: - return ValidationResult.error("Date must be in YYYY-MM-DD format") - - if date_str[4] != '-' or date_str[7] != '-': - return ValidationResult.error("Date must be in YYYY-MM-DD format") - - try: - year = int(date_str[0:4]) - month = int(date_str[5:7]) - day = int(date_str[8:10]) - - if not (1 <= month <= 12 and 1 <= day <= 31 and 2000 <= year <= 2100): - return ValidationResult.error("Invalid date values") - - return ValidationResult.ok(year=year, month=month, day=day) - - except ValueError: - return ValidationResult.error("Date must contain valid numbers") - - -# ============================================================================ -# EXCEPTION-RAISING VALIDATORS (for CLI/API use) -# ============================================================================ - -def require_valid_pin(pin: str, required: bool = False) -> None: - """Validate PIN, raising exception on failure.""" - result = validate_pin(pin, required) - if not result.is_valid: - raise PinValidationError(result.error_message) - - -def require_valid_message(message: str) -> None: - """Validate message, raising exception on failure.""" - result = validate_message(message) - if not result.is_valid: - raise MessageValidationError(result.error_message) - - -def require_valid_payload(payload: Union[str, bytes, FilePayload]) -> None: - """Validate payload (text, bytes, or file), raising exception on failure.""" - result = validate_payload(payload) - if not result.is_valid: - raise MessageValidationError(result.error_message) - - -def require_valid_image(image_data: bytes, name: str = "Image") -> None: - """Validate image, raising exception on failure.""" - result = validate_image(image_data, name) - if not result.is_valid: - raise ImageValidationError(result.error_message) - - -def require_valid_rsa_key( - key_data: bytes, - password: Optional[str] = None, - required: bool = False -) -> None: - """Validate RSA key, raising exception on failure.""" - result = validate_rsa_key(key_data, password, required) - if not result.is_valid: - raise KeyValidationError(result.error_message) - - -def require_security_factors(pin: str, rsa_key_data: Optional[bytes]) -> None: - """Validate security factors, raising exception on failure.""" - result = validate_security_factors(pin, rsa_key_data) - if not result.is_valid: - raise SecurityFactorError(result.error_message)