Added debug, additional comments, encoded file thumbnail view.

This commit is contained in:
Aaron D. Lee
2025-12-28 20:18:11 -05:00
parent 541e6424ea
commit 7dd2e2daf7
32 changed files with 5618 additions and 153 deletions

129
Dockerfile.txt Normal file
View File

@@ -0,0 +1,129 @@
# Stegasoo Docker Image
# Multi-stage build for smaller image size
FROM python:3.11-slim as base
# Set environment variables
ENV PYTHONDONTWRITEBYTECODE=1
ENV PYTHONUNBUFFERED=1
# Install system dependencies
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc \
libc-dev \
libffi-dev \
&& rm -rf /var/lib/apt/lists/*
# ============================================================================
# Builder stage - install Python packages
# ============================================================================
FROM base as builder
WORKDIR /build
# Copy package files (including README.md which pyproject.toml references)
COPY pyproject.toml README.md ./
COPY src/ src/
COPY data/ data/
# Install the package with web extras
RUN pip install --no-cache-dir ".[web]"
# ============================================================================
# Production stage - Web UI
# ============================================================================
FROM base as web
WORKDIR /app
# Copy installed packages from builder
COPY --from=builder /usr/local/lib/python3.11/site-packages /usr/local/lib/python3.11/site-packages
COPY --from=builder /usr/local/bin /usr/local/bin
# Copy application files
COPY src/ src/
COPY data/ data/
COPY frontends/web/ frontends/web/
# Create upload directory
RUN mkdir -p /tmp/stego_uploads
# Create non-root user
RUN useradd -m -u 1000 stego && chown -R stego:stego /app /tmp/stego_uploads
USER stego
# Set Python path
ENV PYTHONPATH=/app/src
# Expose port
EXPOSE 5000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:5000/')" || exit 1
# Run with gunicorn
WORKDIR /app/frontends/web
CMD ["gunicorn", "--bind", "0.0.0.0:5000", "--workers", "2", "--threads", "4", "--timeout", "60", "app:app"]
# ============================================================================
# API stage - REST API
# ============================================================================
FROM base as api
WORKDIR /app
# Install API extras
COPY pyproject.toml README.md ./
COPY src/ src/
COPY data/ data/
RUN pip install --no-cache-dir ".[api]"
# Copy API files
COPY frontends/api/ frontends/api/
# Create non-root user
RUN useradd -m -u 1000 stego && chown -R stego:stego /app
USER stego
# Set Python path
ENV PYTHONPATH=/app/src
# Expose port
EXPOSE 8000
# Health check
HEALTHCHECK --interval=30s --timeout=10s --start-period=5s --retries=3 \
CMD python -c "import urllib.request; urllib.request.urlopen('http://localhost:8000/')" || exit 1
# Run with uvicorn
WORKDIR /app/frontends/api
CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"]
# ============================================================================
# CLI stage - Command line tool
# ============================================================================
FROM base as cli
WORKDIR /app
# Install CLI extras
COPY pyproject.toml README.md ./
COPY src/ src/
COPY data/ data/
RUN pip install --no-cache-dir ".[cli]"
# Copy CLI files
COPY frontends/cli/ frontends/cli/
# Create non-root user
RUN useradd -m -u 1000 stego && chown -R stego:stego /app
USER stego
# Set Python path
ENV PYTHONPATH=/app/src
# Default to help
WORKDIR /app/frontends/cli
ENTRYPOINT ["python", "main.py"]
CMD ["--help"]

View File

@@ -13,6 +13,7 @@ import secrets
import mimetypes import mimetypes
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
from PIL import Image
from flask import ( from flask import (
Flask, render_template, request, send_file, Flask, render_template, request, send_file,
@@ -79,15 +80,49 @@ app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE # 10MB max upload
# Temporary file storage for sharing (file_id -> {data, timestamp, filename}) # Temporary file storage for sharing (file_id -> {data, timestamp, filename})
TEMP_FILES: dict[str, dict] = {} TEMP_FILES: dict[str, dict] = {}
THUMBNAIL_FILES: dict[str, bytes] = {}
TEMP_FILE_EXPIRY = 300 # 5 minutes TEMP_FILE_EXPIRY = 300 # 5 minutes
THUMBNAIL_SIZE = (250, 250) # Maximum dimensions for thumbnail
def generate_thumbnail(image_data: bytes, size: tuple = THUMBNAIL_SIZE) -> bytes:
"""Generate thumbnail from image data."""
try:
with Image.open(io.BytesIO(image_data)) as img:
# Convert to RGB if necessary
if img.mode in ('RGBA', 'LA', 'P'):
# Create white background for transparent images
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
img = background
elif img.mode != 'RGB':
img = img.convert('RGB')
# Create thumbnail
img.thumbnail(size, Image.Resampling.LANCZOS)
# Save to bytes
buffer = io.BytesIO()
img.save(buffer, format='JPEG', quality=85, optimize=True)
return buffer.getvalue()
except Exception as e:
# Log error but don't crash
print(f"Thumbnail generation error: {e}")
return None
def cleanup_temp_files(): def cleanup_temp_files():
"""Remove expired temporary files.""" """Remove expired temporary files."""
now = time.time() now = time.time()
expired = [fid for fid, info in TEMP_FILES.items() if now - info['timestamp'] > TEMP_FILE_EXPIRY] expired = [fid for fid, info in TEMP_FILES.items() if now - info['timestamp'] > TEMP_FILE_EXPIRY]
for fid in expired: for fid in expired:
TEMP_FILES.pop(fid, None) TEMP_FILES.pop(fid, None)
# Also clean up corresponding thumbnail
thumb_id = f"{fid}_thumb"
THUMBNAIL_FILES.pop(thumb_id, None)
def allowed_image(filename: str) -> bool: def allowed_image(filename: str) -> bool:
@@ -108,7 +143,6 @@ def format_size(size_bytes: int) -> str:
return f"{size_bytes / (1024 * 1024):.1f} MB" return f"{size_bytes / (1024 * 1024):.1f} MB"
# ============================================================================ # ============================================================================
# ROUTES # ROUTES
# ============================================================================ # ============================================================================
@@ -254,11 +288,6 @@ def generate_qr_download(token):
return f"Error generating QR code: {e}", 500 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']) @app.route('/generate/download-key', methods=['POST'])
def download_key(): def download_key():
"""Download RSA key as password-protected PEM file.""" """Download RSA key as password-protected PEM file."""
@@ -330,32 +359,6 @@ def extract_key_from_qr_route():
'success': False, 'success': False,
'error': str(e) 'error': str(e)
}), 500 }), 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']) @app.route('/encode', methods=['GET', 'POST'])
@@ -516,9 +519,32 @@ def encode_result(file_id):
return redirect(url_for('encode_page')) return redirect(url_for('encode_page'))
file_info = TEMP_FILES[file_id] file_info = TEMP_FILES[file_id]
# Generate thumbnail
thumbnail_data = generate_thumbnail(file_info['data'])
thumbnail_id = None
if thumbnail_data:
thumbnail_id = f"{file_id}_thumb"
THUMBNAIL_FILES[thumbnail_id] = thumbnail_data
return render_template('encode_result.html', return render_template('encode_result.html',
file_id=file_id, file_id=file_id,
filename=file_info['filename'] filename=file_info['filename'],
thumbnail_url=url_for('encode_thumbnail', thumb_id=thumbnail_id) if thumbnail_id else None
)
@app.route('/encode/thumbnail/<thumb_id>')
def encode_thumbnail(thumb_id):
"""Serve thumbnail image."""
if thumb_id not in THUMBNAIL_FILES:
return "Thumbnail not found", 404
return send_file(
io.BytesIO(THUMBNAIL_FILES[thumb_id]),
mimetype='image/jpeg',
as_attachment=False
) )
@@ -556,6 +582,11 @@ def encode_file_route(file_id):
def encode_cleanup(file_id): def encode_cleanup(file_id):
"""Manually cleanup a file after sharing.""" """Manually cleanup a file after sharing."""
TEMP_FILES.pop(file_id, None) TEMP_FILES.pop(file_id, None)
# Also cleanup thumbnail if exists
thumb_id = f"{file_id}_thumb"
THUMBNAIL_FILES.pop(thumb_id, None)
return jsonify({'status': 'ok'}) return jsonify({'status': 'ok'})
@@ -706,4 +737,4 @@ def about():
# ============================================================================ # ============================================================================
if __name__ == '__main__': if __name__ == '__main__':
app.run(host='0.0.0.0', port=5000, debug=False) app.run(host='0.0.0.0', port=5000, debug=False)

View File

@@ -0,0 +1,709 @@
#!/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/<token>')
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/<token>')
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/<file_id>')
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/<file_id>')
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/<file_id>')
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/<file_id>', 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/<file_id>')
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)

View File

@@ -47,6 +47,15 @@ body {
border-bottom: none; 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 { .feature-card {
transition: transform 0.3s ease, box-shadow 0.3s ease; transition: transform 0.3s ease, box-shadow 0.3s ease;
} }
@@ -255,3 +264,137 @@ footer {
.footer-icon { .footer-icon {
vertical-align: text-bottom; 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;
}
}
/* ----------------------------------------------------------------------------
Dramatic Gold Glow Hover Effect
---------------------------------------------------------------------------- */
.embossed-icon {
color: #f8f8f8 !important;
text-shadow:
/* Subtle emboss only by default */
0 0.5px 0 rgba(255, 255, 255, 0.4), /* Soft top highlight */
0 -0.5px 0 rgba(0, 0, 0, 0.15), /* Very soft bottom shadow */
0 1px 2px rgba(0, 0, 0, 0.1); /* Minimal drop shadow */
position: relative;
display: inline-block;
transition: all 0.3s ease;
font-size: 3.5rem !important;
line-height: 1;
padding: 10px 0;
}
.card-link:hover .embossed-icon {
color: #ffffff !important;
text-shadow:
/* GOLD GLOW LAYERS - Only on hover */
0 0 10px rgba(255, 215, 0, 0.5), /* Soft inner glow */
0 0 20px rgba(255, 215, 0, 0.3), /* Medium glow */
0 0 30px rgba(255, 215, 0, 0.2), /* Outer glow */
0 0 40px rgba(255, 215, 0, 0.1), /* Far outer glow */
/* Enhanced emboss on hover */
0 1px 0 rgba(255, 255, 255, 0.8), /* Bright top highlight */
0 -1px 0 rgba(0, 0, 0, 0.25), /* Deeper bottom shadow */
0 2px 4px rgba(0, 0, 0, 0.15), /* Soft drop shadow */
/* Gold accent shadows */
0 1px 2px rgba(255, 215, 0, 0.3), /* Gold highlight layer */
0 -1px 1px rgba(255, 215, 0, 0.15); /* Gold shadow layer */
filter: drop-shadow(0 0 8px rgba(255, 215, 0, 0.25));
transform: scale(1.02); /* Slight grow on hover */
}
/* Card header adjustments for dramatic effect */
.card-link .card-header.text-center {
padding-top: 1.25rem !important;
padding-bottom: 1.25rem !important;
min-height: 6.5rem;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: all 0.3s ease;
}
/* Enhance the gradient on hover for dramatic effect */
.card-link:hover .card-header.text-center {
background: linear-gradient(135deg,
var(--gradient-start) 0%,
#5a67d8 20%,
var(--gradient-end) 80%,
#8a2be2 100%);
box-shadow: inset 0 0 20px rgba(255, 215, 0, 0.1);
}
/* Mobile adjustments */
@media (max-width: 768px) {
.embossed-icon {
font-size: 2.8rem !important;
padding: 8px 0;
}
.card-link:hover .embossed-icon {
transform: scale(1.01);
}
.card-link .card-header.text-center {
padding-top: 1rem !important;
padding-bottom: 1rem !important;
min-height: 5.5rem;
}
}
/* ----------------------------------------------------------------------------
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);
}

View File

@@ -0,0 +1,409 @@
/* ============================================================================
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);
}

View File

@@ -133,7 +133,6 @@
<label class="form-label"> <label class="form-label">
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key <i class="bi bi-file-earmark-lock me-1"></i> RSA Key
</label> </label>
{% if has_qrcode_read %}
<ul class="nav nav-tabs nav-tabs-sm mb-2" role="tablist"> <ul class="nav nav-tabs nav-tabs-sm mb-2" role="tablist">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link active py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaFileTabDec" type="button"> <button class="nav-link active py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaFileTabDec" type="button">
@@ -155,9 +154,6 @@
<div class="form-text small">PNG, JPG, or other image of QR code</div> <div class="form-text small">PNG, JPG, or other image of QR code</div>
</div> </div>
</div> </div>
{% else %}
<input type="file" name="rsa_key" class="form-control" id="rsaKeyInput" accept=".pem,.key">
{% endif %}
<div class="form-text"> <div class="form-text">
If RSA key was used during encoding (file or QR image) If RSA key was used during encoding (file or QR image)
</div> </div>

View File

@@ -142,7 +142,6 @@
<label class="form-label"> <label class="form-label">
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key <i class="bi bi-file-earmark-lock me-1"></i> RSA Key
</label> </label>
{% if has_qrcode_read %}
<ul class="nav nav-tabs nav-tabs-sm mb-2" role="tablist"> <ul class="nav nav-tabs nav-tabs-sm mb-2" role="tablist">
<li class="nav-item" role="presentation"> <li class="nav-item" role="presentation">
<button class="nav-link active py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaFileTab" type="button"> <button class="nav-link active py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaFileTab" type="button">
@@ -158,18 +157,13 @@
<div class="tab-content"> <div class="tab-content">
<div class="tab-pane fade show active" id="rsaFileTab" role="tabpanel"> <div class="tab-pane fade show active" id="rsaFileTab" role="tabpanel">
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file"> <input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
<div class="form-text small">Shared .pem format key file.</div>
</div> </div>
<div class="tab-pane fade" id="rsaQrTab" role="tabpanel"> <div class="tab-pane fade" id="rsaQrTab" role="tabpanel">
<input type="file" name="rsa_key_qr" class="form-control form-control-sm" id="rsaKeyQrInput" accept="image/*"> <input type="file" name="rsa_key_qr" class="form-control form-control-sm" id="rsaKeyQrInput" accept="image/*">
<div class="form-text small">PNG, JPG, or other image of QR code</div> <div class="form-text small">PNG, JPG, or other image of QR code</div>
</div> </div>
</div> </div>
{% else %}
<input type="file" name="rsa_key" class="form-control" id="rsaKeyInput" accept=".pem,.key">
{% endif %}
<div class="form-text">
Your shared .pem key file or QR code image (if configured)
</div>
</div> </div>
</div> </div>

View File

@@ -11,7 +11,21 @@
</div> </div>
<div class="card-body text-center"> <div class="card-body text-center">
<div class="my-4"> <div class="my-4">
{% if thumbnail_url %}
<!-- Thumbnail of the actual encoded image -->
<div class="encoded-image-thumbnail">
<img src="{{ thumbnail_url }}"
alt="Encoded image thumbnail"
class="img-thumbnail rounded"
style="max-width: 250px; max-height: 250px; object-fit: contain;">
<div class="mt-2 small text-muted">
<i class="bi bi-image me-1"></i>Encoded Image Preview
</div>
</div>
{% else %}
<!-- Fallback to icon if thumbnail not available -->
<i class="bi bi-file-earmark-image text-success" style="font-size: 4rem;"></i> <i class="bi bi-file-earmark-image text-success" style="font-size: 4rem;"></i>
{% endif %}
</div> </div>
<p class="lead mb-4">Your secret has been hidden in the image.</p> <p class="lead mb-4">Your secret has been hidden in the image.</p>
@@ -92,4 +106,4 @@ document.getElementById('downloadBtn').addEventListener('click', function() {
}, 2000); }, 2000);
}); });
</script> </script>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,95 @@
{% extends "base.html" %}
{% block title %}Encode Success - Stegasoo{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-6">
<div class="card">
<div class="card-header bg-success text-white">
<h5 class="mb-0"><i class="bi bi-check-circle me-2"></i>Encoding Successful!</h5>
</div>
<div class="card-body text-center">
<div class="my-4">
<i class="bi bi-file-earmark-image text-success" style="font-size: 4rem;"></i>
</div>
<p class="lead mb-4">Your secret has been hidden in the image.</p>
<div class="mb-3">
<code class="fs-5">{{ filename }}</code>
</div>
<div class="d-grid gap-2">
<a href="{{ url_for('encode_download', file_id=file_id) }}"
class="btn btn-primary btn-lg" id="downloadBtn">
<i class="bi bi-download me-2"></i>Download Image
</a>
<button type="button" class="btn btn-outline-primary" id="shareBtn" style="display: none;">
<i class="bi bi-share me-2"></i>Share
</button>
</div>
<hr class="my-4">
<div class="alert alert-warning small text-start">
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Important:</strong>
<ul class="mb-0 mt-2">
<li>This file expires in <strong>5 minutes</strong></li>
<li>Do <strong>not</strong> resize or recompress the image</li>
<li>PNG format preserves your hidden data</li>
</ul>
</div>
<a href="{{ url_for('encode_page') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-repeat me-2"></i>Encode Another Message
</a>
</div>
</div>
</div>
</div>
{% endblock %}
{% block scripts %}
<script>
// Web Share API support
const shareBtn = document.getElementById('shareBtn');
const fileUrl = "{{ url_for('encode_file_route', file_id=file_id, _external=True) }}";
const fileName = "{{ filename }}";
if (navigator.share && navigator.canShare) {
// Check if we can share files
fetch(fileUrl)
.then(response => response.blob())
.then(blob => {
const file = new File([blob], fileName, { type: 'image/png' });
if (navigator.canShare({ files: [file] })) {
shareBtn.style.display = 'block';
shareBtn.addEventListener('click', async () => {
try {
await navigator.share({
files: [file],
title: 'Stegasoo Image',
});
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Share failed:', err);
}
}
});
}
})
.catch(err => console.log('Could not load file for sharing'));
}
// Auto-cleanup after download
document.getElementById('downloadBtn').addEventListener('click', function() {
// Give time for download to start, then cleanup
setTimeout(() => {
fetch("{{ url_for('encode_cleanup', file_id=file_id) }}", { method: 'POST' });
}, 2000);
});
</script>
{% endblock %}

View File

@@ -8,57 +8,57 @@
<h1 class="display-4 fw-bold">Stegasoo</h1> <h1 class="display-4 fw-bold">Stegasoo</h1>
<p class="lead text-muted">Create hidden encrypted messages in images and photos using advanced steganography.</p> <p class="lead text-muted">Create hidden encrypted messages in images and photos using advanced steganography.</p>
</div> </div>
<div class="row g-4 mb-5">
<div class="row g-4 mb-5">
<!-- Encode Card -->
<div class="col-md-4"> <div class="col-md-4">
<div class="card h-100 feature-card"> <a href="/encode" class="text-decoration-none card-link">
<div class="card-header text-center py-3"> <div class="card h-100 feature-card">
<i class="bi bi-lock-fill fs-1"></i> <div class="card-header text-center py-3">
<i class="bi bi-lock-fill fs-1 embossed-icon"></i>
</div>
<div class="card-body text-center">
<h5 class="card-title">Encode Message</h5>
<p class="card-text text-muted">
Hide your secret message inside an innocent-looking image using your daily phrase + PIN.
</p>
</div>
</div> </div>
<div class="card-body text-center"> </a>
<h5 class="card-title">Encode Message</h5>
<p class="card-text text-muted">
Hide your secret message inside an innocent-looking image using your daily phrase + PIN.
</p>
<a href="/encode" class="btn btn-primary">
<i class="bi bi-upload me-1"></i> Encode
</a>
</div>
</div>
</div> </div>
<!-- Decode Card -->
<div class="col-md-4"> <div class="col-md-4">
<div class="card h-100 feature-card"> <a href="/decode" class="text-decoration-none card-link">
<div class="card-header text-center py-3"> <div class="card h-100 feature-card">
<i class="bi bi-unlock-fill fs-1"></i> <div class="card-header text-center py-3">
<i class="bi bi-unlock-fill fs-1 embossed-icon"></i>
</div>
<div class="card-body text-center">
<h5 class="card-title">Decode Message</h5>
<p class="card-text text-muted">
Extract and decrypt hidden messages from Stegasoo-encoded images using your credentials.
</p>
</div>
</div> </div>
<div class="card-body text-center"> </a>
<h5 class="card-title">Decode Message</h5>
<p class="card-text text-muted">
Extract and decrypt hidden messages from Stegasoo-encoded images using your credentials.
</p>
<a href="/decode" class="btn btn-primary">
<i class="bi bi-download me-1"></i> Decode
</a>
</div>
</div>
</div> </div>
<!-- Generate Card -->
<div class="col-md-4"> <div class="col-md-4">
<div class="card h-100 feature-card"> <a href="/generate" class="text-decoration-none card-link">
<div class="card-header text-center py-3"> <div class="card h-100 feature-card">
<i class="bi bi-key-fill fs-1"></i> <div class="card-header text-center py-3">
<i class="bi bi-key-fill fs-1 embossed-icon"></i>
</div>
<div class="card-body text-center">
<h5 class="card-title">Generate Keys</h5>
<p class="card-text text-muted">
Create your weekly phrase card and PIN. Memorize 21 words + 6 digits for maximum security.
</p>
</div>
</div> </div>
<div class="card-body text-center"> </a>
<h5 class="card-title">Generate Keys</h5>
<p class="card-text text-muted">
Create your weekly phrase card and PIN. Memorize 21 words + 6 digits for maximum security.
</p>
<a href="/generate" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i> Generate
</a>
</div>
</div>
</div> </div>
</div> </div>
@@ -105,4 +105,4 @@
</div> </div>
</div> </div>
</div> </div>
{% endblock %} {% endblock %}

View File

@@ -0,0 +1,108 @@
{% extends "base.html" %}
{% block title %}Stegasoo - Secure Steganography{% endblock %}
{% block content %}
<div class="text-center mb-5">
<img src="{{ url_for('static', filename='logo.svg') }}" alt="Stegasoo" height="120" class="mb-3">
<h1 class="display-4 fw-bold">Stegasoo</h1>
<p class="lead text-muted">Create hidden encrypted messages in images and photos using advanced steganography.</p>
</div>
<div class="row g-4 mb-5">
<div class="col-md-4">
<div class="card h-100 feature-card">
<div class="card-header text-center py-3">
<i class="bi bi-lock-fill fs-1"></i>
</div>
<div class="card-body text-center">
<h5 class="card-title">Encode Message</h5>
<p class="card-text text-muted">
Hide your secret message inside an innocent-looking image using your daily phrase + PIN.
</p>
<a href="/encode" class="btn btn-primary">
<i class="bi bi-upload me-1"></i> Encode
</a>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 feature-card">
<div class="card-header text-center py-3">
<i class="bi bi-unlock-fill fs-1"></i>
</div>
<div class="card-body text-center">
<h5 class="card-title">Decode Message</h5>
<p class="card-text text-muted">
Extract and decrypt hidden messages from Stegasoo-encoded images using your credentials.
</p>
<a href="/decode" class="btn btn-primary">
<i class="bi bi-download me-1"></i> Decode
</a>
</div>
</div>
</div>
<div class="col-md-4">
<div class="card h-100 feature-card">
<div class="card-header text-center py-3">
<i class="bi bi-key-fill fs-1"></i>
</div>
<div class="card-body text-center">
<h5 class="card-title">Generate Keys</h5>
<p class="card-text text-muted">
Create your weekly phrase card and PIN. Memorize 21 words + 6 digits for maximum security.
</p>
<a href="/generate" class="btn btn-primary">
<i class="bi bi-plus-circle me-1"></i> Generate
</a>
</div>
</div>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-diagram-3 me-2"></i>How It Works</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-primary"><i class="bi bi-1-circle me-2"></i>Key Components</h6>
<ul class="list-unstyled">
<li class="mb-2">
<i class="bi bi-image text-info me-2"></i>
<strong>Reference Photo</strong> — Any photo you and recipient both have
</li>
<li class="mb-2">
<i class="bi bi-chat-quote text-info me-2"></i>
<strong>Day Phrase</strong> — 3 words, different each day of the week
</li>
<li class="mb-2">
<i class="bi bi-123 text-info me-2"></i>
<strong>Static PIN</strong> — 6 digits, same every day
</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-primary"><i class="bi bi-2-circle me-2"></i>Security Features</h6>
<ul class="list-unstyled">
<li class="mb-2">
<i class="bi bi-shield-check text-success me-2"></i>
Argon2id memory-hard key derivation (256MB)
</li>
<li class="mb-2">
<i class="bi bi-shuffle text-success me-2"></i>
Pseudo-random pixel selection (defeats steganalysis)
</li>
<li class="mb-2">
<i class="bi bi-lock text-success me-2"></i>
AES-256-GCM authenticated encryption
</li>
</ul>
</div>
</div>
</div>
</div>
{% endblock %}

View File

@@ -45,15 +45,19 @@ dependencies = [
[project.optional-dependencies] [project.optional-dependencies]
cli = [ cli = [
"click>=8.0.0", "click>=8.0.0",
"qrcode>=7.30"
] ]
web = [ web = [
"flask>=3.0.0", "flask>=3.0.0",
"gunicorn>=21.0.0", "gunicorn>=21.0.0",
"qrcode>=7.3.0",
"pyzbar",
] ]
api = [ api = [
"fastapi>=0.100.0", "fastapi>=0.100.0",
"uvicorn[standard]>=0.20.0", "uvicorn[standard]>=0.20.0",
"python-multipart>=0.0.6", "python-multipart>=0.0.6",
"qrcode>=7.30",
] ]
all = [ all = [
"stegasoo[cli,web,api]", "stegasoo[cli,web,api]",

View File

@@ -9,12 +9,11 @@ Pillow>=10.0.0
argon2-cffi>=23.1.0 argon2-cffi>=23.1.0
# QR Code Generation & Reading # QR Code Generation & Reading
qrcode[pil]>=7.4.0 qrcode>=7.4.0
pyzbar>=0.1.9 pyzbar>=0.1.9
# Web Frontend (Flask) # Web Frontend (Flask)
Flask>=3.0.0 Flask>=3.0.0
pyzbar>=0.1.8
# API Frontend (FastAPI) # API Frontend (FastAPI)
fastapi>=0.109.0 fastapi>=0.109.0

View File

@@ -57,6 +57,11 @@ File Embedding:
f.write(decoded.file_data) f.write(decoded.file_data)
else: else:
print(decoded.message) print(decoded.message)
Debugging:
from stegasoo.debug import debug
debug.enable(True) # Enable debug output
debug.enable_performance(True) # Enable timing
""" """
from .constants import __version__, DAY_NAMES, MAX_MESSAGE_SIZE, MAX_FILE_PAYLOAD_SIZE from .constants import __version__, DAY_NAMES, MAX_MESSAGE_SIZE, MAX_FILE_PAYLOAD_SIZE
@@ -148,6 +153,7 @@ from .utils import (
SecureDeleter, SecureDeleter,
format_file_size, format_file_size,
) )
from .debug import debug # Import debug utilities
# QR Code utilities (optional, depends on qrcode and pyzbar) # QR Code utilities (optional, depends on qrcode and pyzbar)
try: try:
@@ -174,7 +180,7 @@ except ImportError:
from datetime import date from datetime import date
from pathlib import Path from pathlib import Path
from typing import Optional, Union from typing import Optional, Union, Dict, Any
def encode( def encode(
@@ -219,6 +225,10 @@ def encode(
Output format is always lossless (PNG or BMP) to preserve hidden data. 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. If carrier is JPEG/GIF, output will be PNG to maintain data integrity.
""" """
# Debug logging
debug.print(f"encode called: message type={type(message).__name__}, "
f"day_phrase='{day_phrase[:20]}...', pin_length={len(pin)}")
# Validate inputs # Validate inputs
require_valid_payload(message) require_valid_payload(message)
require_valid_image(carrier_image, "Carrier image") require_valid_image(carrier_image, "Carrier image")
@@ -233,16 +243,23 @@ def encode(
if date_str is None: if date_str is None:
date_str = date.today().isoformat() date_str = date.today().isoformat()
debug.print(f"Encoding for date: {date_str}")
# Encrypt message/file # Encrypt message/file
encrypted = encrypt_message( encrypted = encrypt_message(
message, reference_photo, day_phrase, date_str, pin, rsa_key_data message, reference_photo, day_phrase, date_str, pin, rsa_key_data
) )
# Debug: show encrypted data size
debug.print(f"Encrypted payload: {len(encrypted)} bytes")
# Get pixel key # Get pixel key
pixel_key = derive_pixel_key( pixel_key = derive_pixel_key(
reference_photo, day_phrase, date_str, pin, rsa_key_data reference_photo, day_phrase, date_str, pin, rsa_key_data
) )
debug.data(pixel_key, "Pixel key")
# Embed in image (returns extension too) # Embed in image (returns extension too)
stego_data, stats, extension = embed_in_image( stego_data, stats, extension = embed_in_image(
carrier_image, encrypted, pixel_key, output_format=output_format carrier_image, encrypted, pixel_key, output_format=output_format
@@ -251,6 +268,10 @@ def encode(
# Generate filename with correct extension # Generate filename with correct extension
filename = generate_filename(date_str, extension=extension) filename = generate_filename(date_str, extension=extension)
debug.print(f"Encoding complete: {filename}, "
f"modified {stats.pixels_modified}/{stats.total_pixels} pixels "
f"({stats.modification_percent:.2f}%)")
return EncodeResult( return EncodeResult(
stego_image=stego_data, stego_image=stego_data,
filename=filename, filename=filename,
@@ -293,6 +314,7 @@ def encode_file(
Returns: Returns:
EncodeResult with stego image and metadata EncodeResult with stego image and metadata
""" """
debug.print(f"encode_file called: filepath={filepath}")
payload = FilePayload.from_file(str(filepath), filename_override) payload = FilePayload.from_file(str(filepath), filename_override)
return encode( return encode(
@@ -342,6 +364,7 @@ def encode_bytes(
Returns: Returns:
EncodeResult with stego image and metadata EncodeResult with stego image and metadata
""" """
debug.print(f"encode_bytes called: filename={filename}, data_size={len(data)}")
payload = FilePayload(data=data, filename=filename, mime_type=mime_type) payload = FilePayload(data=data, filename=filename, mime_type=mime_type)
return encode( return encode(
@@ -357,6 +380,7 @@ def encode_bytes(
) )
@debug.time
def decode( def decode(
stego_image: bytes, stego_image: bytes,
reference_photo: bytes, reference_photo: bytes,
@@ -393,6 +417,9 @@ def decode(
ExtractionError: If data cannot be extracted ExtractionError: If data cannot be extracted
DecryptionError: If decryption fails DecryptionError: If decryption fails
""" """
debug.print(f"decode called: stego_image_size={len(stego_image)}, "
f"day_phrase='{day_phrase[:20]}...'")
# Validate inputs # Validate inputs
require_security_factors(pin, rsa_key_data) require_security_factors(pin, rsa_key_data)
@@ -407,12 +434,15 @@ def decode(
reference_photo, day_phrase, date_str, pin, rsa_key_data reference_photo, day_phrase, date_str, pin, rsa_key_data
) )
debug.data(pixel_key, "Pixel key for extraction")
encrypted = extract_from_image(stego_image, pixel_key) encrypted = extract_from_image(stego_image, pixel_key)
# If we got data, check if it's from a different date # If we got data, check if it's from a different date
if encrypted: if encrypted:
header = parse_header(encrypted) header = parse_header(encrypted)
if header and header['date'] != date_str: if header and header['date'] != date_str:
debug.print(f"Found different date in header: {header['date']} (expected {date_str})")
# Re-extract with correct date # Re-extract with correct date
pixel_key = derive_pixel_key( pixel_key = derive_pixel_key(
reference_photo, day_phrase, header['date'], pin, rsa_key_data reference_photo, day_phrase, header['date'], pin, rsa_key_data
@@ -420,8 +450,12 @@ def decode(
encrypted = extract_from_image(stego_image, pixel_key) encrypted = extract_from_image(stego_image, pixel_key)
if not encrypted: if not encrypted:
debug.print("No data extracted from image")
raise ExtractionError("Could not extract data. Check your inputs.") raise ExtractionError("Could not extract data. Check your inputs.")
debug.print(f"Extracted {len(encrypted)} bytes from image")
debug.data(encrypted[:64], "First 64 bytes of extracted data")
# Decrypt and return full result # Decrypt and return full result
return decrypt_message(encrypted, reference_photo, day_phrase, pin, rsa_key_data) return decrypt_message(encrypted, reference_photo, day_phrase, pin, rsa_key_data)
@@ -454,6 +488,7 @@ def decode_text(
Raises: Raises:
DecryptionError: If content is a binary file, not text DecryptionError: If content is a binary file, not text
""" """
debug.print("decode_text called")
result = decode(stego_image, reference_photo, day_phrase, pin, rsa_key_data, rsa_password) result = decode(stego_image, reference_photo, day_phrase, pin, rsa_key_data, rsa_password)
if result.is_file: if result.is_file:
@@ -462,12 +497,14 @@ def decode_text(
try: try:
return result.file_data.decode('utf-8') return result.file_data.decode('utf-8')
except UnicodeDecodeError: except UnicodeDecodeError:
debug.print(f"File is binary: {result.filename or 'unnamed'}")
raise DecryptionError( raise DecryptionError(
f"Content is a binary file ({result.filename or 'unnamed'}), not text. " f"Content is a binary file ({result.filename or 'unnamed'}), not text. "
"Use decode() instead and check result.is_file." "Use decode() instead and check result.is_file."
) )
return "" return ""
debug.print(f"Decoded text: {result.message[:100] if result.message else 'empty'}...")
return result.message or "" return result.message or ""
@@ -574,4 +611,7 @@ __all__ = [
'secure_delete', 'secure_delete',
'SecureDeleter', 'SecureDeleter',
'format_file_size', 'format_file_size',
]
# Debugging
'debug',
]

View File

@@ -11,7 +11,7 @@ from pathlib import Path
# VERSION # VERSION
# ============================================================================ # ============================================================================
__version__ = "2.1.1" __version__ = "2.1.3"
# ============================================================================ # ============================================================================
# FILE FORMAT # FILE FORMAT

180
src/stegasoo/debug.py Normal file
View File

@@ -0,0 +1,180 @@
"""
Stegasoo Debugging Utilities
Debugging, logging, and performance monitoring tools.
Can be disabled for production use.
"""
import time
import traceback
from datetime import datetime
from functools import wraps
from typing import Callable, Any, Optional, Dict
import sys
# Global debug configuration
DEBUG_ENABLED = False # Set to True to enable debug output
LOG_PERFORMANCE = True # Log function timing
VALIDATION_ASSERTIONS = True # Enable runtime validation assertions
def enable_debug(enable: bool = True) -> None:
"""Enable or disable debug mode globally."""
global DEBUG_ENABLED
DEBUG_ENABLED = enable
def enable_performance_logging(enable: bool = True) -> None:
"""Enable or disable performance timing."""
global LOG_PERFORMANCE
LOG_PERFORMANCE = enable
def enable_assertions(enable: bool = True) -> None:
"""Enable or disable validation assertions."""
global VALIDATION_ASSERTIONS
VALIDATION_ASSERTIONS = enable
def debug_print(message: str, level: str = "INFO") -> None:
"""Print debug message with timestamp if debugging is enabled."""
if DEBUG_ENABLED:
timestamp = datetime.now().strftime("%H:%M:%S.%f")[:-3]
print(f"[{timestamp}] [{level}] {message}", file=sys.stderr)
def debug_data(data: bytes, label: str = "Data", max_bytes: int = 32) -> str:
"""Format bytes for debugging."""
if not DEBUG_ENABLED:
return ""
if not data:
return f"{label}: Empty"
if len(data) <= max_bytes:
return f"{label} ({len(data)} bytes): {data.hex()}"
else:
return f"{label} ({len(data)} bytes): {data[:max_bytes//2].hex()}...{data[-max_bytes//2:].hex()}"
def debug_exception(e: Exception, context: str = "") -> None:
"""Log exception with context for debugging."""
if DEBUG_ENABLED:
debug_print(f"Exception in {context}: {type(e).__name__}: {e}", "ERROR")
if DEBUG_ENABLED:
traceback.print_exc()
def time_function(func: Callable) -> Callable:
"""Decorator to time function execution for performance debugging."""
@wraps(func)
def wrapper(*args, **kwargs) -> Any:
if not (DEBUG_ENABLED and LOG_PERFORMANCE):
return func(*args, **kwargs)
start = time.perf_counter()
try:
result = func(*args, **kwargs)
return result
finally:
end = time.perf_counter()
debug_print(f"{func.__name__} took {end - start:.6f}s", "PERF")
return wrapper
def validate_assertion(condition: bool, message: str) -> None:
"""Runtime validation that can be disabled in production."""
if VALIDATION_ASSERTIONS and not condition:
raise AssertionError(f"Validation failed: {message}")
def memory_usage() -> Dict[str, float]:
"""Get current memory usage (if psutil is available)."""
try:
import psutil
import os
process = psutil.Process(os.getpid())
mem_info = process.memory_info()
return {
'rss_mb': mem_info.rss / 1024 / 1024, # Resident Set Size
'vms_mb': mem_info.vms / 1024 / 1024, # Virtual Memory Size
'percent': process.memory_percent(),
}
except ImportError:
return {'error': 'psutil not installed'}
def hexdump(data: bytes, offset: int = 0, length: int = 64) -> str:
"""Create hexdump string for debugging binary data."""
if not data:
return "Empty"
result = []
data_to_dump = data[:length]
for i in range(0, len(data_to_dump), 16):
chunk = data_to_dump[i:i+16]
hex_str = ' '.join(f'{b:02x}' for b in chunk)
hex_str = hex_str.ljust(47) # Pad to consistent width
ascii_str = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk)
result.append(f"{offset + i:08x}: {hex_str} {ascii_str}")
if len(data) > length:
result.append(f"... ({len(data) - length} more bytes)")
return '\n'.join(result)
# Create singleton instance for easy import
class Debug:
"""Debugging utility class."""
def __init__(self):
self.enabled = DEBUG_ENABLED
def print(self, message: str, level: str = "INFO") -> None:
"""Print debug message."""
debug_print(message, level)
def data(self, data: bytes, label: str = "Data", max_bytes: int = 32) -> str:
"""Format bytes for debugging."""
return debug_data(data, label, max_bytes)
def exception(self, e: Exception, context: str = "") -> None:
"""Log exception with context."""
debug_exception(e, context)
def time(self, func: Callable) -> Callable:
"""Decorator to time function execution."""
return time_function(func)
def validate(self, condition: bool, message: str) -> None:
"""Runtime validation assertion."""
validate_assertion(condition, message)
def memory(self) -> Dict[str, float]:
"""Get current memory usage."""
return memory_usage()
def hexdump(self, data: bytes, offset: int = 0, length: int = 64) -> str:
"""Create hexdump string."""
return hexdump(data, offset, length)
def enable(self, enable: bool = True) -> None:
"""Enable or disable debug mode."""
enable_debug(enable)
self.enabled = enable
def enable_performance(self, enable: bool = True) -> None:
"""Enable or disable performance logging."""
enable_performance_logging(enable)
def enable_assertions(self, enable: bool = True) -> None:
"""Enable or disable validation assertions."""
enable_assertions(enable)
# Create singleton instance
debug = Debug()

View File

@@ -5,7 +5,7 @@ Generate PINs, passphrases, and RSA keys.
""" """
import secrets import secrets
from typing import Optional from typing import Optional, Dict
from cryptography.hazmat.primitives.asymmetric import rsa from cryptography.hazmat.primitives.asymmetric import rsa
from cryptography.hazmat.primitives import serialization from cryptography.hazmat.primitives import serialization
@@ -21,6 +21,7 @@ from .constants import (
) )
from .models import Credentials, KeyInfo from .models import Credentials, KeyInfo
from .exceptions import KeyGenerationError, KeyPasswordError from .exceptions import KeyGenerationError, KeyPasswordError
from .debug import debug
def generate_pin(length: int = DEFAULT_PIN_LENGTH) -> str: def generate_pin(length: int = DEFAULT_PIN_LENGTH) -> str:
@@ -34,7 +35,14 @@ def generate_pin(length: int = DEFAULT_PIN_LENGTH) -> str:
Returns: Returns:
PIN string PIN string
Example:
>>> generate_pin(6)
"812345"
""" """
debug.validate(length >= MIN_PIN_LENGTH and length <= MAX_PIN_LENGTH,
f"PIN length must be between {MIN_PIN_LENGTH} and {MAX_PIN_LENGTH}")
length = max(MIN_PIN_LENGTH, min(MAX_PIN_LENGTH, length)) length = max(MIN_PIN_LENGTH, min(MAX_PIN_LENGTH, length))
# First digit: 1-9 (no leading zero) # First digit: 1-9 (no leading zero)
@@ -43,7 +51,9 @@ def generate_pin(length: int = DEFAULT_PIN_LENGTH) -> str:
# Remaining digits: 0-9 # Remaining digits: 0-9
rest = ''.join(str(secrets.randbelow(10)) for _ in range(length - 1)) rest = ''.join(str(secrets.randbelow(10)) for _ in range(length - 1))
return first_digit + rest pin = first_digit + rest
debug.print(f"Generated PIN: {pin}")
return pin
def generate_phrase(words_per_phrase: int = DEFAULT_PHRASE_WORDS) -> str: def generate_phrase(words_per_phrase: int = DEFAULT_PHRASE_WORDS) -> str:
@@ -55,15 +65,24 @@ def generate_phrase(words_per_phrase: int = DEFAULT_PHRASE_WORDS) -> str:
Returns: Returns:
Space-separated phrase Space-separated phrase
Example:
>>> generate_phrase(3)
"apple forest thunder"
""" """
debug.validate(words_per_phrase >= MIN_PHRASE_WORDS and words_per_phrase <= MAX_PHRASE_WORDS,
f"Words per phrase must be between {MIN_PHRASE_WORDS} and {MAX_PHRASE_WORDS}")
words_per_phrase = max(MIN_PHRASE_WORDS, min(MAX_PHRASE_WORDS, words_per_phrase)) words_per_phrase = max(MIN_PHRASE_WORDS, min(MAX_PHRASE_WORDS, words_per_phrase))
wordlist = get_wordlist() wordlist = get_wordlist()
words = [secrets.choice(wordlist) for _ in range(words_per_phrase)] words = [secrets.choice(wordlist) for _ in range(words_per_phrase)]
return ' '.join(words) phrase = ' '.join(words)
debug.print(f"Generated phrase: {phrase}")
return phrase
def generate_day_phrases(words_per_phrase: int = DEFAULT_PHRASE_WORDS) -> dict[str, str]: def generate_day_phrases(words_per_phrase: int = DEFAULT_PHRASE_WORDS) -> Dict[str, str]:
""" """
Generate phrases for all days of the week. Generate phrases for all days of the week.
@@ -72,8 +91,14 @@ def generate_day_phrases(words_per_phrase: int = DEFAULT_PHRASE_WORDS) -> dict[s
Returns: Returns:
Dict mapping day names to phrases Dict mapping day names to phrases
Example:
>>> generate_day_phrases(3)
{'Monday': 'apple forest thunder', 'Tuesday': 'banana river lightning', ...}
""" """
return {day: generate_phrase(words_per_phrase) for day in DAY_NAMES} phrases = {day: generate_phrase(words_per_phrase) for day in DAY_NAMES}
debug.print(f"Generated phrases for {len(phrases)} days")
return phrases
def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey: def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey:
@@ -88,17 +113,29 @@ def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey:
Raises: Raises:
KeyGenerationError: If generation fails KeyGenerationError: If generation fails
Example:
>>> key = generate_rsa_key(2048)
>>> key.key_size
2048
""" """
debug.validate(bits in VALID_RSA_SIZES,
f"RSA key size must be one of {VALID_RSA_SIZES}")
if bits not in VALID_RSA_SIZES: if bits not in VALID_RSA_SIZES:
bits = DEFAULT_RSA_BITS bits = DEFAULT_RSA_BITS
debug.print(f"Generating {bits}-bit RSA key...")
try: try:
return rsa.generate_private_key( key = rsa.generate_private_key(
public_exponent=65537, public_exponent=65537,
key_size=bits, key_size=bits,
backend=default_backend() backend=default_backend()
) )
debug.print(f"RSA key generated: {bits} bits")
return key
except Exception as e: except Exception as e:
debug.exception(e, "RSA key generation")
raise KeyGenerationError(f"Failed to generate RSA key: {e}") from e raise KeyGenerationError(f"Failed to generate RSA key: {e}") from e
@@ -115,11 +152,21 @@ def export_rsa_key_pem(
Returns: Returns:
PEM-encoded key bytes PEM-encoded key bytes
Example:
>>> key = generate_rsa_key()
>>> pem = export_rsa_key_pem(key)
>>> pem[:50]
b'-----BEGIN PRIVATE KEY-----\\nMIIEvAIBADANBgkqhkiG9w0BAQEFAASCBKYw'
""" """
debug.validate(private_key is not None, "Private key cannot be None")
if password: if password:
encryption = serialization.BestAvailableEncryption(password.encode()) encryption = serialization.BestAvailableEncryption(password.encode())
debug.print("Exporting RSA key with encryption")
else: else:
encryption = serialization.NoEncryption() encryption = serialization.NoEncryption()
debug.print("Exporting RSA key without encryption")
return private_key.private_bytes( return private_key.private_bytes(
encoding=serialization.Encoding.PEM, encoding=serialization.Encoding.PEM,
@@ -145,17 +192,31 @@ def load_rsa_key(
Raises: Raises:
KeyPasswordError: If password is wrong or missing KeyPasswordError: If password is wrong or missing
KeyGenerationError: If key is invalid KeyGenerationError: If key is invalid
Example:
>>> key = load_rsa_key(pem_data, "my_password")
""" """
debug.validate(key_data is not None and len(key_data) > 0,
"Key data cannot be empty")
try: try:
pwd_bytes = password.encode() if password else None pwd_bytes = password.encode() if password else None
return load_pem_private_key(key_data, password=pwd_bytes, backend=default_backend()) debug.print(f"Loading RSA key (encrypted: {bool(password)})")
key = load_pem_private_key(key_data, password=pwd_bytes, backend=default_backend())
debug.print(f"RSA key loaded: {key.key_size} bits")
return key
except TypeError: except TypeError:
debug.print("RSA key is password-protected but no password provided")
raise KeyPasswordError("RSA key is password-protected. Please provide the password.") raise KeyPasswordError("RSA key is password-protected. Please provide the password.")
except ValueError as e: except ValueError as e:
if "password" in str(e).lower() or "encrypted" in str(e).lower(): error_msg = str(e).lower()
if "password" in error_msg or "encrypted" in error_msg:
debug.print("Incorrect password for RSA key")
raise KeyPasswordError("Incorrect password for RSA key.") raise KeyPasswordError("Incorrect password for RSA key.")
debug.exception(e, "RSA key loading")
raise KeyGenerationError(f"Invalid RSA key: {e}") from e raise KeyGenerationError(f"Invalid RSA key: {e}") from e
except Exception as e: except Exception as e:
debug.exception(e, "RSA key loading")
raise KeyGenerationError(f"Could not load RSA key: {e}") from e raise KeyGenerationError(f"Could not load RSA key: {e}") from e
@@ -169,17 +230,28 @@ def get_key_info(key_data: bytes, password: Optional[str] = None) -> KeyInfo:
Returns: Returns:
KeyInfo with key size and encryption status KeyInfo with key size and encryption status
Example:
>>> info = get_key_info(pem_data)
>>> info.key_size
2048
>>> info.is_encrypted
False
""" """
debug.print("Getting RSA key info")
# Check if encrypted # Check if encrypted
is_encrypted = b'ENCRYPTED' in key_data is_encrypted = b'ENCRYPTED' in key_data
private_key = load_rsa_key(key_data, password) private_key = load_rsa_key(key_data, password)
return KeyInfo( info = KeyInfo(
key_size=private_key.key_size, key_size=private_key.key_size,
is_encrypted=is_encrypted, is_encrypted=is_encrypted,
pem_data=key_data pem_data=key_data
) )
debug.print(f"Key info: {info.key_size} bits, encrypted: {info.is_encrypted}")
return info
def generate_credentials( def generate_credentials(
@@ -206,23 +278,40 @@ def generate_credentials(
Raises: Raises:
ValueError: If neither PIN nor RSA is selected ValueError: If neither PIN nor RSA is selected
Example:
>>> creds = generate_credentials(use_pin=True, use_rsa=False)
>>> creds.pin
"812345"
>>> creds.phrases['Monday']
"apple forest thunder"
""" """
debug.validate(use_pin or use_rsa,
"Must select at least one security factor (PIN or RSA key)")
if not use_pin and not use_rsa: if not use_pin and not use_rsa:
raise ValueError("Must select at least one security factor (PIN or RSA key)") raise ValueError("Must select at least one security factor (PIN or RSA key)")
debug.print(f"Generating credentials: PIN={use_pin}, RSA={use_rsa}, "
f"words={words_per_phrase}")
phrases = generate_day_phrases(words_per_phrase) phrases = generate_day_phrases(words_per_phrase)
pin = generate_pin(pin_length) if use_pin else None pin = generate_pin(pin_length) if use_pin else None
rsa_key_pem = None rsa_key_pem = None
rsa_key_obj = None
if use_rsa: if use_rsa:
private_key = generate_rsa_key(rsa_bits) rsa_key_obj = generate_rsa_key(rsa_bits)
rsa_key_pem = export_rsa_key_pem(private_key).decode('utf-8') rsa_key_pem = export_rsa_key_pem(rsa_key_obj).decode('utf-8')
return Credentials( creds = Credentials(
phrases=phrases, phrases=phrases,
pin=pin, pin=pin,
rsa_key_pem=rsa_key_pem, rsa_key_pem=rsa_key_pem,
rsa_bits=rsa_bits if use_rsa else None, rsa_bits=rsa_bits if use_rsa else None,
words_per_phrase=words_per_phrase words_per_phrase=words_per_phrase
) )
debug.print(f"Credentials generated: {creds.total_entropy} bits total entropy")
return creds

View File

@@ -6,7 +6,7 @@ LSB embedding and extraction with pseudo-random pixel selection.
import io import io
import struct import struct
from typing import Optional from typing import Optional, Tuple, List
from PIL import Image from PIL import Image
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms from cryptography.hazmat.primitives.ciphers import Cipher, algorithms
@@ -14,6 +14,7 @@ from cryptography.hazmat.backends import default_backend
from .models import EmbedStats from .models import EmbedStats
from .exceptions import CapacityError, ExtractionError, EmbeddingError from .exceptions import CapacityError, ExtractionError, EmbeddingError
from .debug import debug
# Lossless formats that preserve LSB data # Lossless formats that preserve LSB data
@@ -35,7 +36,7 @@ EXT_TO_FORMAT = {
} }
def get_output_format(input_format: Optional[str]) -> tuple[str, str]: def get_output_format(input_format: Optional[str]) -> Tuple[str, str]:
""" """
Determine the output format based on input format. Determine the output format based on input format.
@@ -45,15 +46,29 @@ def get_output_format(input_format: Optional[str]) -> tuple[str, str]:
Returns: Returns:
Tuple of (PIL format string, file extension) for output Tuple of (PIL format string, file extension) for output
Falls back to PNG for lossy or unknown formats. Falls back to PNG for lossy or unknown formats.
Example:
>>> get_output_format('JPEG')
('PNG', 'png')
>>> get_output_format('PNG')
('PNG', 'png')
""" """
debug.validate(input_format is None or isinstance(input_format, str),
"Input format must be string or None")
if input_format and input_format.upper() in LOSSLESS_FORMATS: if input_format and input_format.upper() in LOSSLESS_FORMATS:
fmt = input_format.upper() fmt = input_format.upper()
return fmt, FORMAT_TO_EXT.get(fmt, 'png') ext = FORMAT_TO_EXT.get(fmt, 'png')
debug.print(f"Using lossless format: {fmt} -> .{ext}")
return fmt, ext
# Default to PNG for lossy formats (JPEG, GIF) or unknown # Default to PNG for lossy formats (JPEG, GIF) or unknown
debug.print(f"Input format {input_format} is lossy or unknown, defaulting to PNG")
return 'PNG', 'png' return 'PNG', 'png'
def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list[int]: @debug.time
def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> List[int]:
""" """
Generate pseudo-random pixel indices for embedding. Generate pseudo-random pixel indices for embedding.
@@ -67,9 +82,21 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list
Returns: Returns:
List of pixel indices List of pixel indices
Note:
Optimizes for both small and large num_needed values.
""" """
debug.validate(len(key) == 32, f"Pixel key must be 32 bytes, got {len(key)}")
debug.validate(num_pixels > 0, f"Number of pixels must be positive, got {num_pixels}")
debug.validate(num_needed > 0, f"Number needed must be positive, got {num_needed}")
debug.validate(num_needed <= num_pixels,
f"Cannot select {num_needed} pixels from {num_pixels} available")
debug.print(f"Generating {num_needed} pixel indices from {num_pixels} total pixels")
if num_needed >= num_pixels // 2: if num_needed >= num_pixels // 2:
# If we need many pixels, shuffle all indices # If we need many pixels, shuffle all indices
debug.print(f"Using full shuffle (needed {num_needed}/{num_pixels} pixels)")
nonce = b'\x00' * 16 nonce = b'\x00' * 16
cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend()) cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend())
encryptor = cipher.encryptor() encryptor = cipher.encryptor()
@@ -77,14 +104,18 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list
indices = list(range(num_pixels)) indices = list(range(num_pixels))
random_bytes = encryptor.update(b'\x00' * (num_pixels * 4)) random_bytes = encryptor.update(b'\x00' * (num_pixels * 4))
# Fisher-Yates shuffle using CSPRNG
for i in range(num_pixels - 1, 0, -1): for i in range(num_pixels - 1, 0, -1):
j_bytes = random_bytes[(num_pixels - 1 - i) * 4:(num_pixels - i) * 4] j_bytes = random_bytes[(num_pixels - 1 - i) * 4:(num_pixels - i) * 4]
j = int.from_bytes(j_bytes, 'big') % (i + 1) j = int.from_bytes(j_bytes, 'big') % (i + 1)
indices[i], indices[j] = indices[j], indices[i] indices[i], indices[j] = indices[j], indices[i]
return indices[:num_needed] selected = indices[:num_needed]
debug.print(f"Generated {len(selected)} indices via shuffle")
return selected
# Optimized path: generate indices directly # Optimized path: generate indices directly (for smaller selections)
debug.print(f"Using optimized selection (needed {num_needed}/{num_pixels} pixels)")
selected = [] selected = []
used = set() used = set()
@@ -97,6 +128,7 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list
random_bytes = encryptor.update(b'\x00' * bytes_needed) random_bytes = encryptor.update(b'\x00' * bytes_needed)
byte_offset = 0 byte_offset = 0
collisions = 0
while len(selected) < num_needed and byte_offset < len(random_bytes) - 4: 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 idx = int.from_bytes(random_bytes[byte_offset:byte_offset + 4], 'big') % num_pixels
byte_offset += 4 byte_offset += 4
@@ -104,25 +136,36 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list
if idx not in used: if idx not in used:
used.add(idx) used.add(idx)
selected.append(idx) selected.append(idx)
else:
collisions += 1
# Generate additional if needed (rare) # Generate additional if needed (rare)
while len(selected) < num_needed: if len(selected) < num_needed:
extra_bytes = encryptor.update(b'\x00' * 4) debug.print(f"Need {num_needed - len(selected)} more indices, generating...")
idx = int.from_bytes(extra_bytes, 'big') % num_pixels extra_needed = num_needed - len(selected)
if idx not in used: for _ in range(extra_needed * 2): # Try twice as many to account for collisions
used.add(idx) extra_bytes = encryptor.update(b'\x00' * 4)
selected.append(idx) idx = int.from_bytes(extra_bytes, 'big') % num_pixels
if idx not in used:
used.add(idx)
selected.append(idx)
if len(selected) == num_needed:
break
debug.print(f"Generated {len(selected)} indices with {collisions} collisions")
debug.validate(len(selected) == num_needed,
f"Failed to generate enough indices: {len(selected)}/{num_needed}")
return selected return selected
@debug.time
def embed_in_image( def embed_in_image(
carrier_data: bytes, carrier_data: bytes,
encrypted_data: bytes, encrypted_data: bytes,
pixel_key: bytes, pixel_key: bytes,
bits_per_channel: int = 1, bits_per_channel: int = 1,
output_format: Optional[str] = None output_format: Optional[str] = None
) -> tuple[bytes, EmbedStats, str]: ) -> Tuple[bytes, EmbedStats, str]:
""" """
Embed encrypted data in carrier image using LSB steganography. Embed encrypted data in carrier image using LSB steganography.
@@ -143,12 +186,27 @@ def embed_in_image(
Raises: Raises:
CapacityError: If carrier is too small CapacityError: If carrier is too small
EmbeddingError: If embedding fails EmbeddingError: If embedding fails
Example:
>>> stego_bytes, stats, ext = embed_in_image(carrier, encrypted, key)
>>> stats.pixels_modified
1500
""" """
debug.print(f"Embedding {len(encrypted_data)} bytes into image")
debug.data(pixel_key, "Pixel key for embedding")
debug.validate(bits_per_channel in (1, 2),
f"bits_per_channel must be 1 or 2, got {bits_per_channel}")
debug.validate(len(pixel_key) == 32,
f"Pixel key must be 32 bytes, got {len(pixel_key)}")
try: try:
img = Image.open(io.BytesIO(carrier_data)) img = Image.open(io.BytesIO(carrier_data))
input_format = img.format input_format = img.format
debug.print(f"Carrier image: {img.size[0]}x{img.size[1]}, format: {input_format}")
if img.mode != 'RGB': if img.mode != 'RGB':
debug.print(f"Converting image from {img.mode} to RGB")
img = img.convert('RGB') img = img.convert('RGB')
pixels = list(img.getdata()) pixels = list(img.getdata())
@@ -157,16 +215,24 @@ def embed_in_image(
bits_per_pixel = 3 * bits_per_channel bits_per_pixel = 3 * bits_per_channel
max_bytes = (num_pixels * bits_per_pixel) // 8 max_bytes = (num_pixels * bits_per_pixel) // 8
debug.print(f"Image capacity: {max_bytes} bytes at {bits_per_channel} bit(s)/channel")
# Prepend length # Prepend length
data_with_len = struct.pack('>I', len(encrypted_data)) + encrypted_data data_with_len = struct.pack('>I', len(encrypted_data)) + encrypted_data
if len(data_with_len) > max_bytes: if len(data_with_len) > max_bytes:
debug.print(f"Capacity error: need {len(data_with_len)}, have {max_bytes}")
raise CapacityError(len(data_with_len), max_bytes) raise CapacityError(len(data_with_len), max_bytes)
debug.print(f"Total data to embed: {len(data_with_len)} bytes "
f"({len(data_with_len)/max_bytes*100:.1f}% of capacity)")
# Convert to binary string # Convert to binary string
binary_data = ''.join(format(b, '08b') for b in data_with_len) binary_data = ''.join(format(b, '08b') for b in data_with_len)
pixels_needed = (len(binary_data) + bits_per_pixel - 1) // bits_per_pixel pixels_needed = (len(binary_data) + bits_per_pixel - 1) // bits_per_pixel
debug.print(f"Need {pixels_needed} pixels to embed {len(binary_data)} bits")
# Get pixel indices # Get pixel indices
selected_indices = generate_pixel_indices(pixel_key, num_pixels, pixels_needed) selected_indices = generate_pixel_indices(pixel_key, num_pixels, pixels_needed)
@@ -175,11 +241,14 @@ def embed_in_image(
clear_mask = 0xFF ^ ((1 << bits_per_channel) - 1) clear_mask = 0xFF ^ ((1 << bits_per_channel) - 1)
bit_idx = 0 bit_idx = 0
modified_pixels = 0
for pixel_idx in selected_indices: for pixel_idx in selected_indices:
if bit_idx >= len(binary_data): if bit_idx >= len(binary_data):
break break
r, g, b = new_pixels[pixel_idx] r, g, b = new_pixels[pixel_idx]
modified = False
for channel_idx, channel_val in enumerate([r, g, b]): for channel_idx, channel_val in enumerate([r, g, b]):
if bit_idx >= len(binary_data): if bit_idx >= len(binary_data):
@@ -187,16 +256,22 @@ def embed_in_image(
bits = binary_data[bit_idx:bit_idx + bits_per_channel].ljust(bits_per_channel, '0') bits = binary_data[bit_idx:bit_idx + bits_per_channel].ljust(bits_per_channel, '0')
new_val = (channel_val & clear_mask) | int(bits, 2) new_val = (channel_val & clear_mask) | int(bits, 2)
if channel_idx == 0: if channel_val != new_val:
r = new_val modified = True
elif channel_idx == 1: if channel_idx == 0:
g = new_val r = new_val
else: elif channel_idx == 1:
b = new_val g = new_val
else:
b = new_val
bit_idx += bits_per_channel bit_idx += bits_per_channel
new_pixels[pixel_idx] = (r, g, b) if modified:
new_pixels[pixel_idx] = (r, g, b)
modified_pixels += 1
debug.print(f"Modified {modified_pixels} pixels (out of {len(selected_indices)} selected)")
# Create output image # Create output image
stego_img = Image.new('RGB', img.size) stego_img = Image.new('RGB', img.size)
@@ -206,28 +281,33 @@ def embed_in_image(
if output_format: if output_format:
out_fmt = output_format.upper() out_fmt = output_format.upper()
out_ext = FORMAT_TO_EXT.get(out_fmt, 'png') out_ext = FORMAT_TO_EXT.get(out_fmt, 'png')
debug.print(f"Using forced output format: {out_fmt}")
else: else:
out_fmt, out_ext = get_output_format(input_format) out_fmt, out_ext = get_output_format(input_format)
debug.print(f"Auto-selected output format: {out_fmt}")
output = io.BytesIO() output = io.BytesIO()
stego_img.save(output, out_fmt) stego_img.save(output, out_fmt)
output.seek(0) output.seek(0)
stats = EmbedStats( stats = EmbedStats(
pixels_modified=len(selected_indices), pixels_modified=modified_pixels,
total_pixels=num_pixels, total_pixels=num_pixels,
capacity_used=len(data_with_len) / max_bytes, capacity_used=len(data_with_len) / max_bytes,
bytes_embedded=len(data_with_len) bytes_embedded=len(data_with_len)
) )
debug.print(f"Embedding complete: {out_fmt} image, {len(output.getvalue())} bytes")
return output.getvalue(), stats, out_ext return output.getvalue(), stats, out_ext
except CapacityError: except CapacityError:
raise raise
except Exception as e: except Exception as e:
debug.exception(e, "embed_in_image")
raise EmbeddingError(f"Failed to embed data: {e}") from e raise EmbeddingError(f"Failed to embed data: {e}") from e
@debug.time
def extract_from_image( def extract_from_image(
image_data: bytes, image_data: bytes,
pixel_key: bytes, pixel_key: bytes,
@@ -246,18 +326,35 @@ def extract_from_image(
Raises: Raises:
ExtractionError: If extraction fails critically ExtractionError: If extraction fails critically
Example:
>>> extracted = extract_from_image(stego_bytes, key)
>>> len(extracted)
1024
""" """
debug.print(f"Extracting from {len(image_data)} byte image")
debug.data(pixel_key, "Pixel key for extraction")
debug.validate(bits_per_channel in (1, 2),
f"bits_per_channel must be 1 or 2, got {bits_per_channel}")
try: try:
img = Image.open(io.BytesIO(image_data)) img = Image.open(io.BytesIO(image_data))
debug.print(f"Image: {img.size[0]}x{img.size[1]}, format: {img.format}")
if img.mode != 'RGB': if img.mode != 'RGB':
debug.print(f"Converting image from {img.mode} to RGB")
img = img.convert('RGB') img = img.convert('RGB')
pixels = list(img.getdata()) pixels = list(img.getdata())
num_pixels = len(pixels) num_pixels = len(pixels)
bits_per_pixel = 3 * bits_per_channel bits_per_pixel = 3 * bits_per_channel
debug.print(f"Image has {num_pixels} pixels, {bits_per_pixel} bits/pixel")
# First, extract enough to get the length (4 bytes = 32 bits) # First, extract enough to get the length (4 bytes = 32 bits)
initial_pixels = (32 + bits_per_pixel - 1) // bits_per_pixel + 10 initial_pixels = (32 + bits_per_pixel - 1) // bits_per_pixel + 10
debug.print(f"Extracting initial {initial_pixels} pixels to find length")
initial_indices = generate_pixel_indices(pixel_key, num_pixels, initial_pixels) initial_indices = generate_pixel_indices(pixel_key, num_pixels, initial_pixels)
binary_data = '' binary_data = ''
@@ -270,19 +367,28 @@ def extract_from_image(
# Parse length # Parse length
try: try:
length_bits = binary_data[:32] length_bits = binary_data[:32]
if len(length_bits) < 32:
debug.print(f"Not enough bits for length: {len(length_bits)}/32")
return None
data_length = struct.unpack('>I', int(length_bits, 2).to_bytes(4, 'big'))[0] data_length = struct.unpack('>I', int(length_bits, 2).to_bytes(4, 'big'))[0]
except Exception: debug.print(f"Extracted length: {data_length} bytes")
except Exception as e:
debug.print(f"Failed to parse length: {e}")
return None return None
# Sanity check # Sanity check
max_possible = (num_pixels * bits_per_pixel) // 8 - 4 max_possible = (num_pixels * bits_per_pixel) // 8 - 4
if data_length > max_possible or data_length < 10: if data_length > max_possible or data_length < 10:
debug.print(f"Invalid data length: {data_length} (max possible: {max_possible})")
return None return None
# Extract full data # Extract full data
total_bits = (4 + data_length) * 8 total_bits = (4 + data_length) * 8
pixels_needed = (total_bits + bits_per_pixel - 1) // bits_per_pixel pixels_needed = (total_bits + bits_per_pixel - 1) // bits_per_pixel
debug.print(f"Need {pixels_needed} pixels to extract {data_length} bytes")
selected_indices = generate_pixel_indices(pixel_key, num_pixels, pixels_needed) selected_indices = generate_pixel_indices(pixel_key, num_pixels, pixels_needed)
binary_data = '' binary_data = ''
@@ -294,15 +400,21 @@ def extract_from_image(
data_bits = binary_data[32:32 + (data_length * 8)] data_bits = binary_data[32:32 + (data_length * 8)]
if len(data_bits) < data_length * 8:
debug.print(f"Insufficient bits: {len(data_bits)} < {data_length * 8}")
return None
data_bytes = bytearray() data_bytes = bytearray()
for i in range(0, len(data_bits), 8): for i in range(0, len(data_bits), 8):
byte_bits = data_bits[i:i + 8] byte_bits = data_bits[i:i + 8]
if len(byte_bits) == 8: if len(byte_bits) == 8:
data_bytes.append(int(byte_bits, 2)) data_bytes.append(int(byte_bits, 2))
debug.print(f"Successfully extracted {len(data_bytes)} bytes")
return bytes(data_bytes) return bytes(data_bytes)
except Exception as e: except Exception as e:
debug.exception(e, "extract_from_image")
raise ExtractionError(f"Failed to extract data: {e}") from e raise ExtractionError(f"Failed to extract data: {e}") from e
@@ -316,7 +428,15 @@ def calculate_capacity(image_data: bytes, bits_per_channel: int = 1) -> int:
Returns: Returns:
Maximum bytes that can be embedded (minus overhead) Maximum bytes that can be embedded (minus overhead)
Example:
>>> capacity = calculate_capacity(image_bytes)
>>> capacity
12000
""" """
debug.validate(bits_per_channel in (1, 2),
f"bits_per_channel must be 1 or 2, got {bits_per_channel}")
img = Image.open(io.BytesIO(image_data)) img = Image.open(io.BytesIO(image_data))
if img.mode != 'RGB': if img.mode != 'RGB':
img = img.convert('RGB') img = img.convert('RGB')
@@ -326,25 +446,74 @@ def calculate_capacity(image_data: bytes, bits_per_channel: int = 1) -> int:
max_bytes = (num_pixels * bits_per_pixel) // 8 max_bytes = (num_pixels * bits_per_pixel) // 8
# Subtract overhead: 4 bytes length + ~100 bytes header # Subtract overhead: 4 bytes length + ~100 bytes header
return max(0, max_bytes - 104) capacity = max(0, max_bytes - 104)
debug.print(f"Image capacity: {capacity} bytes at {bits_per_channel} bit(s)/channel")
return capacity
def get_image_dimensions(image_data: bytes) -> tuple[int, int]: def get_image_dimensions(image_data: bytes) -> Tuple[int, int]:
"""Get image dimensions without loading full image.""" """
Get image dimensions without loading full image.
Args:
image_data: Image bytes
Returns:
Tuple of (width, height)
Example:
>>> width, height = get_image_dimensions(image_bytes)
>>> width, height
(800, 600)
"""
debug.validate(len(image_data) > 0, "Image data cannot be empty")
img = Image.open(io.BytesIO(image_data)) img = Image.open(io.BytesIO(image_data))
return img.size dimensions = img.size
debug.print(f"Image dimensions: {dimensions[0]}x{dimensions[1]}")
return dimensions
def get_image_format(image_data: bytes) -> Optional[str]: def get_image_format(image_data: bytes) -> Optional[str]:
"""Get image format (PIL format string like 'PNG', 'JPEG').""" """
Get image format (PIL format string like 'PNG', 'JPEG').
Args:
image_data: Image bytes
Returns:
Format string or None if invalid
Example:
>>> format = get_image_format(image_bytes)
>>> format
'PNG'
"""
try: try:
img = Image.open(io.BytesIO(image_data)) img = Image.open(io.BytesIO(image_data))
return img.format format_str = img.format
except Exception: debug.print(f"Image format: {format_str}")
return format_str
except Exception as e:
debug.print(f"Failed to get image format: {e}")
return None return None
def is_lossless_format(image_data: bytes) -> bool: def is_lossless_format(image_data: bytes) -> bool:
"""Check if image is in a lossless format suitable for steganography.""" """
Check if image is in a lossless format suitable for steganography.
Args:
image_data: Image bytes
Returns:
True if format is lossless (PNG, BMP, TIFF)
Example:
>>> is_lossless_format(image_bytes)
True
"""
fmt = get_image_format(image_data) fmt = get_image_format(image_data)
return fmt is not None and fmt.upper() in LOSSLESS_FORMATS is_lossless = fmt is not None and fmt.upper() in LOSSLESS_FORMATS
debug.print(f"Image is lossless: {is_lossless} (format: {fmt})")
return is_lossless

View File

@@ -10,9 +10,10 @@ import secrets
import shutil import shutil
from datetime import date, datetime from datetime import date, datetime
from pathlib import Path from pathlib import Path
from typing import Optional from typing import Optional, Union
from .constants import DAY_NAMES from .constants import DAY_NAMES
from .debug import debug
def generate_filename( def generate_filename(
@@ -32,7 +33,14 @@ def generate_filename(
Returns: Returns:
Filename string Filename string
Example:
>>> generate_filename("2023-12-25", "secret_", "png")
"secret_a1b2c3d4_20231225.png"
""" """
debug.validate(extension and '.' not in extension,
f"Extension must not contain dot, got '{extension}'")
if date_str is None: if date_str is None:
date_str = date.today().isoformat() date_str = date.today().isoformat()
@@ -42,7 +50,9 @@ def generate_filename(
# Ensure extension doesn't have a leading dot # Ensure extension doesn't have a leading dot
extension = extension.lstrip('.') extension = extension.lstrip('.')
return f"{prefix}{random_hex}_{date_compact}.{extension}" filename = f"{prefix}{random_hex}_{date_compact}.{extension}"
debug.print(f"Generated filename: {filename}")
return filename
def parse_date_from_filename(filename: str) -> Optional[str]: def parse_date_from_filename(filename: str) -> Optional[str]:
@@ -56,6 +66,10 @@ def parse_date_from_filename(filename: str) -> Optional[str]:
Returns: Returns:
Date string (YYYY-MM-DD) or None Date string (YYYY-MM-DD) or None
Example:
>>> parse_date_from_filename("secret_a1b2c3d4_20231225.png")
"2023-12-25"
""" """
import re import re
@@ -63,14 +77,19 @@ def parse_date_from_filename(filename: str) -> Optional[str]:
match = re.search(r'_(\d{4})(\d{2})(\d{2})(?:\.|$)', filename) match = re.search(r'_(\d{4})(\d{2})(\d{2})(?:\.|$)', filename)
if match: if match:
year, month, day = match.groups() year, month, day = match.groups()
return f"{year}-{month}-{day}" date_str = f"{year}-{month}-{day}"
debug.print(f"Parsed date (compact): {date_str}")
return date_str
# Try YYYY-MM-DD format # Try YYYY-MM-DD format
match = re.search(r'_(\d{4})-(\d{2})-(\d{2})(?:\.|$)', filename) match = re.search(r'_(\d{4})-(\d{2})-(\d{2})(?:\.|$)', filename)
if match: if match:
year, month, day = match.groups() year, month, day = match.groups()
return f"{year}-{month}-{day}" date_str = f"{year}-{month}-{day}"
debug.print(f"Parsed date (dashed): {date_str}")
return date_str
debug.print(f"No date found in filename: {filename}")
return None return None
@@ -83,23 +102,55 @@ def get_day_from_date(date_str: str) -> str:
Returns: Returns:
Day name (e.g., "Monday") Day name (e.g., "Monday")
Example:
>>> get_day_from_date("2023-12-25")
"Monday"
""" """
debug.validate(len(date_str) == 10 and date_str[4] == '-' and date_str[7] == '-',
f"Invalid date format: {date_str}, expected YYYY-MM-DD")
try: try:
year, month, day = map(int, date_str.split('-')) year, month, day = map(int, date_str.split('-'))
d = date(year, month, day) d = date(year, month, day)
return DAY_NAMES[d.weekday()] day_name = DAY_NAMES[d.weekday()]
except Exception: debug.print(f"Date {date_str} is {day_name}")
return day_name
except Exception as e:
debug.exception(e, f"get_day_from_date for {date_str}")
return "" return ""
def get_today_date() -> str: def get_today_date() -> str:
"""Get today's date as YYYY-MM-DD.""" """
return date.today().isoformat() Get today's date as YYYY-MM-DD.
Returns:
Today's date string
Example:
>>> get_today_date()
"2023-12-25"
"""
today = date.today().isoformat()
debug.print(f"Today's date: {today}")
return today
def get_today_day() -> str: def get_today_day() -> str:
"""Get today's day name.""" """
return DAY_NAMES[date.today().weekday()] Get today's day name.
Returns:
Today's day name
Example:
>>> get_today_day()
"Monday"
"""
today_day = DAY_NAMES[date.today().weekday()]
debug.print(f"Today is {today_day}")
return today_day
class SecureDeleter: class SecureDeleter:
@@ -107,9 +158,13 @@ class SecureDeleter:
Securely delete files by overwriting with random data. Securely delete files by overwriting with random data.
Implements multi-pass overwriting before deletion. Implements multi-pass overwriting before deletion.
Example:
>>> deleter = SecureDeleter("secret.txt", passes=3)
>>> deleter.execute()
""" """
def __init__(self, path: str | Path, passes: int = 7): def __init__(self, path: Union[str, Path], passes: int = 7):
""" """
Initialize secure deleter. Initialize secure deleter.
@@ -117,66 +172,99 @@ class SecureDeleter:
path: Path to file or directory path: Path to file or directory
passes: Number of overwrite passes passes: Number of overwrite passes
""" """
debug.validate(passes > 0, f"Passes must be positive, got {passes}")
self.path = Path(path) self.path = Path(path)
self.passes = passes self.passes = passes
debug.print(f"SecureDeleter initialized for {self.path} with {passes} passes")
def _overwrite_file(self, file_path: Path) -> None: def _overwrite_file(self, file_path: Path) -> None:
"""Overwrite file with random data multiple times.""" """Overwrite file with random data multiple times."""
if not file_path.exists() or not file_path.is_file(): if not file_path.exists() or not file_path.is_file():
debug.print(f"File does not exist or is not a file: {file_path}")
return return
length = file_path.stat().st_size length = file_path.stat().st_size
debug.print(f"Overwriting file {file_path} ({length} bytes)")
if length == 0: if length == 0:
debug.print("File is empty, nothing to overwrite")
return return
patterns = [b'\x00', b'\xFF', bytes([random.randint(0, 255)])] patterns = [b'\x00', b'\xFF', bytes([random.randint(0, 255)])]
for _ in range(self.passes): for pass_num in range(self.passes):
debug.print(f"Overwrite pass {pass_num + 1}/{self.passes}")
with open(file_path, 'r+b') as f: with open(file_path, 'r+b') as f:
for pattern in patterns: for pattern_idx, pattern in enumerate(patterns):
f.seek(0) f.seek(0)
for _ in range(length): # Write pattern in chunks for large files
f.write(pattern) chunk_size = 1024 * 1024 # 1MB chunks
for offset in range(0, length, chunk_size):
chunk = min(chunk_size, length - offset)
f.write(pattern * (chunk // len(pattern)))
f.write(pattern[:chunk % len(pattern)])
# Final pass with random data # Final pass with random data
f.seek(0) f.seek(0)
f.write(os.urandom(length)) f.write(os.urandom(length))
debug.print(f"Completed {self.passes} overwrite passes")
def delete_file(self) -> None: def delete_file(self) -> None:
"""Securely delete a single file.""" """Securely delete a single file."""
if self.path.is_file(): if self.path.is_file():
debug.print(f"Securely deleting file: {self.path}")
self._overwrite_file(self.path) self._overwrite_file(self.path)
self.path.unlink() self.path.unlink()
debug.print(f"File deleted: {self.path}")
else:
debug.print(f"Not a file: {self.path}")
def delete_directory(self) -> None: def delete_directory(self) -> None:
"""Securely delete a directory and all contents.""" """Securely delete a directory and all contents."""
if not self.path.is_dir(): if not self.path.is_dir():
debug.print(f"Not a directory: {self.path}")
return return
debug.print(f"Securely deleting directory: {self.path}")
# First, securely overwrite all files # First, securely overwrite all files
file_count = 0
for file_path in self.path.rglob('*'): for file_path in self.path.rglob('*'):
if file_path.is_file(): if file_path.is_file():
self._overwrite_file(file_path) self._overwrite_file(file_path)
file_count += 1
debug.print(f"Overwrote {file_count} files")
# Then remove the directory tree # Then remove the directory tree
shutil.rmtree(self.path) shutil.rmtree(self.path)
debug.print(f"Directory deleted: {self.path}")
def execute(self) -> None: def execute(self) -> None:
"""Securely delete the path (file or directory).""" """Securely delete the path (file or directory)."""
debug.print(f"Executing secure deletion: {self.path}")
if self.path.is_file(): if self.path.is_file():
self.delete_file() self.delete_file()
elif self.path.is_dir(): elif self.path.is_dir():
self.delete_directory() self.delete_directory()
else:
debug.print(f"Path does not exist: {self.path}")
def secure_delete(path: str | Path, passes: int = 7) -> None: def secure_delete(path: Union[str, Path], passes: int = 7) -> None:
""" """
Convenience function for secure deletion. Convenience function for secure deletion.
Args: Args:
path: Path to file or directory path: Path to file or directory
passes: Number of overwrite passes passes: Number of overwrite passes
Example:
>>> secure_delete("secret.txt", passes=3)
""" """
debug.print(f"secure_delete called: {path}, passes={passes}")
SecureDeleter(path, passes).execute() SecureDeleter(path, passes).execute()
@@ -189,7 +277,13 @@ def format_file_size(size_bytes: int) -> str:
Returns: Returns:
Human-readable string (e.g., "1.5 MB") Human-readable string (e.g., "1.5 MB")
Example:
>>> format_file_size(1500000)
"1.5 MB"
""" """
debug.validate(size_bytes >= 0, f"File size cannot be negative: {size_bytes}")
for unit in ['B', 'KB', 'MB', 'GB']: for unit in ['B', 'KB', 'MB', 'GB']:
if size_bytes < 1024: if size_bytes < 1024:
if unit == 'B': if unit == 'B':
@@ -200,10 +294,38 @@ def format_file_size(size_bytes: int) -> str:
def format_number(n: int) -> str: def format_number(n: int) -> str:
"""Format number with commas.""" """
Format number with commas.
Args:
n: Integer to format
Returns:
Formatted string
Example:
>>> format_number(1234567)
"1,234,567"
"""
debug.validate(isinstance(n, int), f"Input must be integer, got {type(n)}")
return f"{n:,}" return f"{n:,}"
def clamp(value: int, min_val: int, max_val: int) -> int: def clamp(value: int, min_val: int, max_val: int) -> int:
"""Clamp value to range.""" """
Clamp value to range.
Args:
value: Value to clamp
min_val: Minimum allowed value
max_val: Maximum allowed value
Returns:
Clamped value
Example:
>>> clamp(15, 0, 10)
10
"""
debug.validate(min_val <= max_val, f"min_val ({min_val}) must be <= max_val ({max_val})")
return max(min_val, min(max_val, value)) return max(min_val, min(max_val, value))

0
src_20251228/__init__.py Normal file
View File

11
src_20251228/main.py Normal file
View File

@@ -0,0 +1,11 @@
#!/usr/bin/env python3
"""Main entry point."""
def main():
"""Main function."""
print("Hello, World!")
if __name__ == "__main__":
main()

View File

@@ -0,0 +1,577 @@
"""
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',
]

View File

@@ -0,0 +1,66 @@
"""
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 <command>")
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()

View File

@@ -0,0 +1,131 @@
"""
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

View File

@@ -0,0 +1,512 @@
"""
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

View File

@@ -0,0 +1,150 @@
"""
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))}"
)

View File

@@ -0,0 +1,228 @@
"""
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
)

View File

@@ -0,0 +1,177 @@
"""
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)

View File

@@ -0,0 +1,397 @@
"""
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

View File

@@ -0,0 +1,350 @@
"""
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

View File

@@ -0,0 +1,209 @@
"""
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))

View File

@@ -0,0 +1,426 @@
"""
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)