diff --git a/frontends/api/main.py b/frontends/api/main.py index 72d9f81..fc6ec56 100644 --- a/frontends/api/main.py +++ b/frontends/api/main.py @@ -3,7 +3,7 @@ Stegasoo REST API FastAPI-based REST API for steganography operations. -Designed for integration with other services and automation. +Supports both text messages and file embedding. """ import io @@ -28,6 +28,8 @@ from stegasoo import ( DAY_NAMES, __version__, StegasooError, DecryptionError, CapacityError, has_argon2, + FilePayload, + MAX_FILE_PAYLOAD_SIZE, ) from stegasoo.constants import ( MIN_PIN_LENGTH, MAX_PIN_LENGTH, @@ -42,7 +44,7 @@ from stegasoo.constants import ( app = FastAPI( title="Stegasoo API", - description="Secure steganography with hybrid authentication", + description="Secure steganography with hybrid authentication. Supports text messages and file embedding.", version=__version__, docs_url="/docs", redoc_url="/redoc", @@ -79,6 +81,20 @@ class EncodeRequest(BaseModel): date_str: Optional[str] = None +class EncodeFileRequest(BaseModel): + """Request for embedding a file (base64-encoded).""" + file_data_base64: str + filename: str + mime_type: Optional[str] = None + reference_photo_base64: str + carrier_image_base64: str + day_phrase: str + pin: str = "" + rsa_key_base64: Optional[str] = None + rsa_password: Optional[str] = None + date_str: Optional[str] = None + + class EncodeResponse(BaseModel): stego_image_base64: str filename: str @@ -97,7 +113,12 @@ class DecodeRequest(BaseModel): class DecodeResponse(BaseModel): - message: str + """Response for decode - can be text or file.""" + payload_type: str # 'text' or 'file' + message: Optional[str] = None # For text + file_data_base64: Optional[str] = None # For file (base64-encoded) + filename: Optional[str] = None # For file + mime_type: Optional[str] = None # For file class ImageInfoResponse(BaseModel): @@ -112,6 +133,7 @@ class StatusResponse(BaseModel): version: str has_argon2: bool day_names: list[str] + max_payload_kb: int class ErrorResponse(BaseModel): @@ -129,7 +151,8 @@ async def root(): return StatusResponse( version=__version__, has_argon2=has_argon2(), - day_names=list(DAY_NAMES) + day_names=list(DAY_NAMES), + max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024 ) @@ -173,7 +196,7 @@ async def api_generate(request: GenerateRequest): @app.post("/encode", response_model=EncodeResponse) async def api_encode(request: EncodeRequest): """ - Encode a secret message into an image. + Encode a text message into an image. Images must be base64-encoded. Returns base64-encoded stego image. """ @@ -194,8 +217,55 @@ async def api_encode(request: EncodeRequest): ) stego_b64 = base64.b64encode(result.stego_image).decode('utf-8') + day_of_week = get_day_from_date(result.date_used) - # Get day of week from the date used + return EncodeResponse( + stego_image_base64=stego_b64, + filename=result.filename, + capacity_used_percent=result.capacity_percent, + date_used=result.date_used, + day_of_week=day_of_week + ) + + except CapacityError as e: + raise HTTPException(400, str(e)) + except StegasooError as e: + raise HTTPException(400, str(e)) + except Exception as e: + raise HTTPException(500, str(e)) + + +@app.post("/encode/file", response_model=EncodeResponse) +async def api_encode_file(request: EncodeFileRequest): + """ + Encode a file into an image (JSON with base64). + + File data must be base64-encoded. + """ + try: + file_data = base64.b64decode(request.file_data_base64) + ref_photo = base64.b64decode(request.reference_photo_base64) + carrier = base64.b64decode(request.carrier_image_base64) + rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None + + payload = FilePayload( + data=file_data, + filename=request.filename, + mime_type=request.mime_type + ) + + result = encode( + message=payload, + reference_photo=ref_photo, + carrier_image=carrier, + day_phrase=request.day_phrase, + pin=request.pin, + rsa_key_data=rsa_key, + rsa_password=request.rsa_password, + date_str=request.date_str + ) + + stego_b64 = base64.b64encode(result.stego_image).decode('utf-8') day_of_week = get_day_from_date(result.date_used) return EncodeResponse( @@ -217,16 +287,16 @@ async def api_encode(request: EncodeRequest): @app.post("/decode", response_model=DecodeResponse) async def api_decode(request: DecodeRequest): """ - Decode a secret message from a stego image. + Decode a message or file from a stego image. - Images must be base64-encoded. + Returns payload_type to indicate if result is text or file. """ try: stego = base64.b64decode(request.stego_image_base64) ref_photo = base64.b64decode(request.reference_photo_base64) rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None - message = decode( + result = decode( stego_image=stego, reference_photo=ref_photo, day_phrase=request.day_phrase, @@ -235,7 +305,18 @@ async def api_decode(request: DecodeRequest): rsa_password=request.rsa_password ) - return DecodeResponse(message=message) + if result.is_file: + return DecodeResponse( + payload_type='file', + file_data_base64=base64.b64encode(result.file_data).decode('utf-8'), + filename=result.filename, + mime_type=result.mime_type + ) + else: + return DecodeResponse( + payload_type='text', + message=result.message + ) except DecryptionError as e: raise HTTPException(401, "Decryption failed. Check credentials.") @@ -247,10 +328,11 @@ async def api_decode(request: DecodeRequest): @app.post("/encode/multipart") async def api_encode_multipart( - message: str = Form(...), day_phrase: str = Form(...), reference_photo: UploadFile = File(...), carrier: UploadFile = File(...), + message: str = Form(""), + payload_file: Optional[UploadFile] = File(None), pin: str = Form(""), rsa_key: Optional[UploadFile] = File(None), rsa_password: str = Form(""), @@ -259,6 +341,7 @@ async def api_encode_multipart( """ Encode using multipart form data (file uploads). + Provide either 'message' (text) or 'payload_file' (binary file). Returns the stego image directly as PNG with metadata headers. """ try: @@ -266,8 +349,21 @@ async def api_encode_multipart( carrier_data = await carrier.read() rsa_key_data = await rsa_key.read() if rsa_key else None + # Determine payload + if payload_file and payload_file.filename: + file_data = await payload_file.read() + payload = FilePayload( + data=file_data, + filename=payload_file.filename, + mime_type=payload_file.content_type + ) + elif message: + payload = message + else: + raise HTTPException(400, "Must provide either 'message' or 'payload_file'") + result = encode( - message=message, + message=payload, reference_photo=ref_data, carrier_image=carrier_data, day_phrase=day_phrase, @@ -277,7 +373,6 @@ async def api_encode_multipart( date_str=date_str if date_str else None ) - # Get day of week from the date used day_of_week = get_day_from_date(result.date_used) return Response( @@ -310,13 +405,15 @@ async def api_decode_multipart( ): """ Decode using multipart form data (file uploads). + + Returns JSON with payload_type indicating text or file. """ try: ref_data = await reference_photo.read() stego_data = await stego_image.read() rsa_key_data = await rsa_key.read() if rsa_key else None - message = decode( + result = decode( stego_image=stego_data, reference_photo=ref_data, day_phrase=day_phrase, @@ -325,7 +422,18 @@ async def api_decode_multipart( rsa_password=rsa_password if rsa_password else None ) - return DecodeResponse(message=message) + if result.is_file: + return DecodeResponse( + payload_type='file', + file_data_base64=base64.b64encode(result.file_data).decode('utf-8'), + filename=result.filename, + mime_type=result.mime_type + ) + else: + return DecodeResponse( + payload_type='text', + message=result.message + ) except DecryptionError: raise HTTPException(401, "Decryption failed. Check credentials.") diff --git a/frontends/cli/main.py b/frontends/cli/main.py index 6cb80d2..a6a281a 100644 --- a/frontends/cli/main.py +++ b/frontends/cli/main.py @@ -20,12 +20,14 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src')) import stegasoo from stegasoo import ( - encode, decode, generate_credentials, + encode, encode_file, decode, + generate_credentials, export_rsa_key_pem, load_rsa_key, validate_image, calculate_capacity, get_day_from_date, parse_date_from_filename, DAY_NAMES, __version__, StegasooError, DecryptionError, ExtractionError, + FilePayload, ) @@ -42,7 +44,7 @@ def cli(): """ Stegasoo - Secure steganography with hybrid authentication. - Hide encrypted messages in images using a combination of: + Hide encrypted messages or files in images using a combination of: \b • Reference photo (something you have) @@ -170,8 +172,9 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): @cli.command() @click.option('--ref', '-r', required=True, type=click.Path(exists=True), help='Reference photo') @click.option('--carrier', '-c', required=True, type=click.Path(exists=True), help='Carrier image') -@click.option('--message', '-m', help='Message to encode (or use stdin)') -@click.option('--message-file', '-f', type=click.Path(exists=True), help='Read message from file') +@click.option('--message', '-m', help='Text message to encode') +@click.option('--message-file', '-f', type=click.Path(exists=True), help='Read text message from file') +@click.option('--embed-file', '-e', type=click.Path(exists=True), help='Embed a file (binary)') @click.option('--phrase', '-p', required=True, help='Day phrase') @click.option('--pin', help='Static PIN') @click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file') @@ -179,28 +182,46 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): @click.option('--output', '-o', type=click.Path(), help='Output file (default: auto-generated)') @click.option('--date', 'date_str', help='Date override (YYYY-MM-DD)') @click.option('--quiet', '-q', is_flag=True, help='Suppress output except errors') -def encode_cmd(ref, carrier, message, message_file, phrase, pin, key, key_password, output, date_str, quiet): +def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key, key_password, output, date_str, quiet): """ - Encode a secret message into an image. + Encode a secret message or file into an image. Requires a reference photo, carrier image, and day phrase. Must provide either --pin or --key (or both). + For text messages, use -m or -f or pipe via stdin. + For binary files, use -e/--embed-file. + \b Examples: + # Text message stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder" --pin 123456 -m "secret" - echo "secret" | stegasoo encode -r photo.jpg -c meme.png -p "word1 word2 word3" --pin 123456 - stegasoo encode -r photo.jpg -c meme.png -p "words" -k mykey.pem --key-password "pass" + + # Text from file + stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -f message.txt + + # Embed a binary file (PDF, ZIP, etc.) + stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -e secret.pdf + + # Pipe text + echo "secret" | stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 """ - # Get message - if message: - msg = message + # Determine what to encode + payload = None + + if embed_file: + # Binary file embedding + payload = FilePayload.from_file(embed_file) + if not quiet: + click.echo(f"Embedding file: {payload.filename} ({len(payload.data):,} bytes)") + elif message: + payload = message elif message_file: - msg = Path(message_file).read_text() + payload = Path(message_file).read_text() elif not sys.stdin.isatty(): - msg = sys.stdin.read() + payload = sys.stdin.read() else: - raise click.UsageError("Must provide message via -m, -f, or stdin") + raise click.UsageError("Must provide message via -m, -f, -e, or stdin") # Load key if provided rsa_key_data = None @@ -216,7 +237,7 @@ def encode_cmd(ref, carrier, message, message_file, phrase, pin, key, key_passwo carrier_image = Path(carrier).read_bytes() result = encode( - message=msg, + message=payload, reference_photo=ref_photo, carrier_image=carrier_image, day_phrase=phrase, @@ -259,19 +280,26 @@ def encode_cmd(ref, carrier, message, message_file, phrase, pin, key, key_passwo @click.option('--pin', help='Static PIN') @click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file') @click.option('--key-password', help='RSA key password') -@click.option('--output', '-o', type=click.Path(), help='Save message to file') -@click.option('--quiet', '-q', is_flag=True, help='Output only the message') -def decode_cmd(ref, stego, phrase, pin, key, key_password, output, quiet): +@click.option('--output', '-o', type=click.Path(), help='Save decoded content to file') +@click.option('--quiet', '-q', is_flag=True, help='Output only the content (for text) or suppress messages (for files)') +@click.option('--force', is_flag=True, help='Overwrite existing output file') +def decode_cmd(ref, stego, phrase, pin, key, key_password, output, quiet, force): """ - Decode a secret message from a stego image. + Decode a secret message or file from a stego image. Must use the same credentials that were used for encoding. + Automatically detects whether content is text or a file. \b Examples: + # Decode and print text stegasoo decode -r photo.jpg -s stego.png -p "apple forest thunder" --pin 123456 - stegasoo decode -r photo.jpg -s stego.png -p "words" -k mykey.pem --key-password "pass" - stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 -o message.txt + + # Decode and save (auto-detect type) + stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 -o output.txt + + # Quiet mode for piping text + stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 -q | less """ # Load key if provided rsa_key_data = None @@ -286,7 +314,7 @@ def decode_cmd(ref, stego, phrase, pin, key, key_password, output, quiet): ref_photo = Path(ref).read_bytes() stego_image = Path(stego).read_bytes() - message = decode( + result = decode( stego_image=stego_image, reference_photo=ref_photo, day_phrase=phrase, @@ -295,18 +323,42 @@ def decode_cmd(ref, stego, phrase, pin, key, key_password, output, quiet): rsa_password=key_password, ) - if output: - Path(output).write_text(message) - if not quiet: - click.secho(f"✓ Decoded successfully!", fg='green') - click.echo(f" Saved to: {output}") - else: - if quiet: - click.echo(message) + if result.is_file: + # File content + if output: + out_path = Path(output) + elif result.filename: + out_path = Path(result.filename) else: - click.secho("✓ Decoded successfully!", fg='green') - click.echo() - click.echo(message) + out_path = Path("decoded_file") + + if out_path.exists() and not force: + raise click.ClickException( + f"Output file '{out_path}' exists. Use --force to overwrite." + ) + + out_path.write_bytes(result.file_data) + + if not quiet: + click.secho("✓ Decoded file successfully!", fg='green') + click.echo(f" Saved to: {out_path}") + click.echo(f" Size: {len(result.file_data):,} bytes") + if result.mime_type: + click.echo(f" Type: {result.mime_type}") + else: + # Text content + if output: + Path(output).write_text(result.message) + if not quiet: + click.secho("✓ Decoded successfully!", fg='green') + click.echo(f" Saved to: {output}") + else: + if quiet: + click.echo(result.message) + else: + click.secho("✓ Decoded successfully!", fg='green') + click.echo() + click.echo(result.message) except (DecryptionError, ExtractionError) as e: raise click.ClickException(f"Decryption failed: {e}") diff --git a/frontends/web/app.py b/frontends/web/app.py index f854d70..f12a2cf 100644 --- a/frontends/web/app.py +++ b/frontends/web/app.py @@ -3,13 +3,14 @@ Stegasoo Web Frontend Flask-based web UI for steganography operations. -This is a thin wrapper around the stegasoo library. +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 @@ -27,14 +28,17 @@ from stegasoo import ( 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, + VALID_RSA_SIZES, MAX_FILE_SIZE, ) @@ -44,7 +48,7 @@ from stegasoo.constants import ( app = Flask(__name__) app.secret_key = secrets.token_hex(32) -app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024 # 5MB max upload +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] = {} @@ -67,6 +71,16 @@ def allowed_image(filename: str) -> bool: 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 # ============================================================================ @@ -164,6 +178,7 @@ def download_key(): @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: @@ -171,30 +186,50 @@ def encode_page(): 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) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) 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) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) # 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') - # Validate 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) + # 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) + + 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) + payload = message if not day_phrase: flash('Day phrase is required', 'error') - return render_template('encode.html', day_of_week=day_of_week) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) # Read files ref_data = ref_photo.read() @@ -205,27 +240,27 @@ def encode_page(): 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) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) # 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) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) # Validate RSA key if provided if rsa_key_data: result = validate_rsa_key(rsa_key_data, rsa_password if rsa_password else None) if not result.is_valid: flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) # 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) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) # Get date client_date = request.form.get('client_date', '').strip() @@ -236,7 +271,7 @@ def encode_page(): # Encode encode_result = encode( - message=message, + message=payload, reference_photo=ref_data, carrier_image=carrier_data, day_phrase=day_phrase, @@ -259,15 +294,15 @@ def encode_page(): except CapacityError as e: flash(str(e), 'error') - return render_template('encode.html', day_of_week=day_of_week) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) except StegasooError as e: flash(str(e), 'error') - return render_template('encode.html', day_of_week=day_of_week) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) except Exception as e: flash(f'Error: {e}', 'error') - return render_template('encode.html', day_of_week=day_of_week) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) - return render_template('encode.html', day_of_week=day_of_week) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) @app.route('/encode/result/') @@ -299,7 +334,7 @@ def encode_download(file_id): @app.route('/encode/file/') -def 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 @@ -368,7 +403,7 @@ def decode_page(): return render_template('decode.html') # Decode - message = decode( + decode_result = decode( stego_image=stego_data, reference_photo=ref_data, day_phrase=day_phrase, @@ -377,7 +412,29 @@ def decode_page(): rsa_password=rsa_password if rsa_password else None ) - return render_template('decode.html', decoded_message=message) + 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') @@ -392,9 +449,30 @@ def decode_page(): return render_template('decode.html') +@app.route('/decode/download/') +def decode_download(file_id): + """Download decoded file.""" + if file_id not in TEMP_FILES: + flash('File expired or not found.', 'error') + return redirect(url_for('decode_page')) + + file_info = TEMP_FILES[file_id] + mime_type = file_info.get('mime_type', 'application/octet-stream') + + return send_file( + io.BytesIO(file_info['data']), + mimetype=mime_type, + as_attachment=True, + download_name=file_info['filename'] + ) + + @app.route('/about') def about(): - return render_template('about.html', has_argon2=has_argon2()) + return render_template('about.html', + has_argon2=has_argon2(), + max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024 + ) # ============================================================================ diff --git a/frontends/web/templates/decode.html b/frontends/web/templates/decode.html index 237c83c..cfb28e6 100644 --- a/frontends/web/templates/decode.html +++ b/frontends/web/templates/decode.html @@ -7,10 +7,11 @@
-
Decode Secret Message
+
Decode Secret Message or File
{% if decoded_message %} +
Message Decrypted Successfully!
@@ -23,17 +24,40 @@
- i + + Decode Another + + + {% elif decoded_file %} + +
+
File Decrypted Successfully!
+
+ +
+ +
{{ filename }}
+

{{ file_size }}

+ {% if mime_type %} + Type: {{ mime_type }} + {% endif %} +
+ + + Download File + + +
+ + File expires in 5 minutes. Download now. +
- Decode Another Message + Decode Another {% else %} - +
@@ -66,7 +90,7 @@
- The image containing the hidden message + The image containing the hidden message/file
@@ -130,7 +154,7 @@
@@ -138,6 +162,7 @@ + {% if not decoded_message and not decoded_file %}
Troubleshooting
@@ -165,6 +190,7 @@
+ {% endif %} {% endblock %} @@ -209,7 +235,7 @@ function updateDayLabel(dayName) { } } -// 1. PIN Toggle +// PIN Toggle document.getElementById('togglePin')?.addEventListener('click', function() { const input = document.getElementById('pinInput'); const icon = this.querySelector('i'); @@ -222,9 +248,8 @@ document.getElementById('togglePin')?.addEventListener('click', function() { } }); -// 2. Paste from Clipboard +// Paste from Clipboard document.addEventListener('paste', function(e) { - // Only run if the form exists (we are not on the success page) if (!document.getElementById('decodeForm')) return; const items = e.clipboardData.items; @@ -232,7 +257,6 @@ document.addEventListener('paste', function(e) { if (items[i].type.indexOf("image") !== -1) { const blob = items[i].getAsFile(); - // Priority for Decode: Fill Stego Image first (most common), then Reference const stegoInput = document.querySelector('input[name="stego_image"]'); const refInput = document.querySelector('input[name="reference_photo"]'); diff --git a/frontends/web/templates/encode.html b/frontends/web/templates/encode.html index 5790eaa..a6b37f5 100644 --- a/frontends/web/templates/encode.html +++ b/frontends/web/templates/encode.html @@ -7,7 +7,7 @@
-
Encode Secret Message
+
Encode Secret Message or File
@@ -49,15 +49,34 @@
+
+ +
+ + + + + +
+
+ + +
+ placeholder="Enter your secret message here...">
- 0 / 50,000 characters + 0 / 250,000 characters Getting long! @@ -66,6 +85,29 @@
+ +
+ +
+ +
+ + Drop any file or click to browse +
Max {{ max_payload_kb }} KB
+
+
+
+ Supports any file type: PDF, ZIP, documents, etc. +
+
+ + + () +
+
+
@@ -146,8 +188,8 @@ Limits: Carrier image max ~4 megapixels (2000×2000). - Files max 5MB each. - Message max 50KB. + Files max 10MB upload. + Payload max {{ max_payload_kb }} KB.
@@ -177,6 +219,57 @@ if (dateInput) { dateInput.value = localDate; } +// Payload type switching +const payloadTextRadio = document.getElementById('payloadText'); +const payloadFileRadio = document.getElementById('payloadFile'); +const textSection = document.getElementById('textPayloadSection'); +const fileSection = document.getElementById('filePayloadSection'); +const messageInput = document.getElementById('messageInput'); +const payloadFileInput = document.getElementById('payloadFileInput'); + +function updatePayloadSection() { + const isText = payloadTextRadio.checked; + textSection.classList.toggle('d-none', !isText); + fileSection.classList.toggle('d-none', isText); + + // Update required attribute + if (isText) { + messageInput.required = true; + payloadFileInput.required = false; + } else { + messageInput.required = false; + payloadFileInput.required = true; + } +} + +payloadTextRadio.addEventListener('change', updatePayloadSection); +payloadFileRadio.addEventListener('change', updatePayloadSection); + +// File payload info display +const fileInfo = document.getElementById('fileInfo'); +const fileInfoName = document.getElementById('fileInfoName'); +const fileInfoSize = document.getElementById('fileInfoSize'); +const payloadDropLabel = document.getElementById('payloadDropLabel'); + +payloadFileInput.addEventListener('change', function() { + if (this.files && this.files[0]) { + const file = this.files[0]; + fileInfoName.textContent = file.name; + fileInfoSize.textContent = formatFileSize(file.size); + fileInfo.classList.remove('d-none'); + payloadDropLabel.innerHTML = `${file.name}`; + } else { + fileInfo.classList.add('d-none'); + payloadDropLabel.innerHTML = `Drop any file or click to browse
Max {{ max_payload_kb }} KB
`; + } +}); + +function formatFileSize(bytes) { + if (bytes < 1024) return bytes + ' B'; + if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'; + return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; +} + // Show RSA password field when key is selected const rsaKeyInput = document.getElementById('rsaKeyInput'); const rsaPasswordGroup = document.getElementById('rsaPasswordGroup'); @@ -192,12 +285,11 @@ document.getElementById('encodeForm').addEventListener('submit', function(e) { btn.disabled = true; }); -// Character counter -const messageInput = document.getElementById('messageInput'); +// Character counter for text const charCount = document.getElementById('charCount'); const charWarning = document.getElementById('charWarning'); const charPercent = document.getElementById('charPercent'); -const maxChars = 50000; +const maxChars = 250000; messageInput.addEventListener('input', function() { const len = this.value.length; @@ -210,11 +302,12 @@ messageInput.addEventListener('input', function() { charCount.classList.toggle('text-danger', len > maxChars * 0.95); }); -// Drag & drop with preview +// Drag & drop with preview for images document.querySelectorAll('.drop-zone').forEach(zone => { const input = zone.querySelector('input[type="file"]'); const label = zone.querySelector('.drop-zone-label'); const preview = zone.querySelector('.drop-zone-preview'); + const isPayloadZone = zone.id === 'payloadDropZone'; ['dragenter', 'dragover'].forEach(evt => { zone.addEventListener(evt, e => { @@ -233,29 +326,38 @@ document.querySelectorAll('.drop-zone').forEach(zone => { zone.addEventListener('drop', e => { if (e.dataTransfer.files.length) { input.files = e.dataTransfer.files; - showPreview(e.dataTransfer.files[0]); + input.dispatchEvent(new Event('change')); + + if (!isPayloadZone) { + showPreview(e.dataTransfer.files[0]); + } } }); - input.addEventListener('change', function() { - if (this.files && this.files[0]) { - showPreview(this.files[0]); - } - }); + if (!isPayloadZone) { + input.addEventListener('change', function() { + if (this.files && this.files[0]) { + showPreview(this.files[0]); + } + }); + } function showPreview(file) { if (!file.type.startsWith('image/')) return; const reader = new FileReader(); reader.onload = e => { - preview.src = e.target.result; - preview.classList.remove('d-none'); + if (preview) { + preview.src = e.target.result; + preview.classList.remove('d-none'); + } label.innerHTML = '' + file.name; }; reader.readAsDataURL(file); } }); -// 1. PIN Toggle Logic + +// PIN Toggle Logic document.getElementById('togglePin').addEventListener('click', function() { const input = document.getElementById('pinInput'); const icon = this.querySelector('i'); @@ -268,17 +370,16 @@ document.getElementById('togglePin').addEventListener('click', function() { } }); -// 2. Prevent Same File Selection +// Prevent Same File Selection function checkDuplicateFiles() { const refInput = document.querySelector('input[name="reference_photo"]'); const carInput = document.querySelector('input[name="carrier"]'); if (refInput.files[0] && carInput.files[0]) { - // Compare name and size as a proxy for identical files if (refInput.files[0].name === carInput.files[0].name && refInput.files[0].size === carInput.files[0].size) { alert("Security Warning: You cannot use the same image for both Reference and Carrier!"); - carInput.value = ''; // Clear carrier + carInput.value = ''; document.getElementById('carrierPreview').classList.add('d-none'); document.querySelector('#carrierDropZone .drop-zone-label').innerHTML = '' + @@ -289,14 +390,13 @@ function checkDuplicateFiles() { document.querySelector('input[name="reference_photo"]').addEventListener('change', checkDuplicateFiles); document.querySelector('input[name="carrier"]').addEventListener('change', checkDuplicateFiles); -// 3. Paste from Clipboard +// Paste from Clipboard document.addEventListener('paste', function(e) { const items = e.clipboardData.items; for (let i = 0; i < items.length; i++) { if (items[i].type.indexOf("image") !== -1) { const blob = items[i].getAsFile(); - // Priority: Fill Carrier first, if empty. Else fill Reference. const carrierInput = document.querySelector('input[name="carrier"]'); const refInput = document.querySelector('input[name="reference_photo"]'); @@ -307,7 +407,7 @@ document.addEventListener('paste', function(e) { targetInput.files = container.files; targetInput.dispatchEvent(new Event('change')); - break; // Only paste one image at a time + break; } } }); diff --git a/src/stegasoo/__init__.py b/src/stegasoo/__init__.py index 9b5af30..b97cedd 100644 --- a/src/stegasoo/__init__.py +++ b/src/stegasoo/__init__.py @@ -1,10 +1,10 @@ """ Stegasoo - Secure Steganography Library -A Python library for hiding encrypted messages in images using +A Python library for hiding encrypted messages and files in images using hybrid photo + passphrase + PIN authentication. -Basic Usage: +Basic Usage - Text Message: from stegasoo import encode, decode, generate_credentials # Generate credentials @@ -30,16 +30,36 @@ Basic Usage: f.write(result.stego_image) # Decode a message - message = decode( + decoded = decode( stego_image=result.stego_image, reference_photo=ref_photo, day_phrase="apple forest thunder", pin="123456" ) - print(message) # "Meet at midnight" + 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 +from .constants import __version__, DAY_NAMES, MAX_MESSAGE_SIZE, MAX_FILE_PAYLOAD_SIZE from .models import ( Credentials, EncodeInput, @@ -49,6 +69,7 @@ from .models import ( EmbedStats, KeyInfo, ValidationResult, + FilePayload, ) from .exceptions import ( StegasooError, @@ -83,6 +104,8 @@ from .keygen import ( from .validation import ( validate_pin, validate_message, + validate_payload, + validate_file_payload, validate_image, validate_rsa_key, validate_security_factors, @@ -90,6 +113,7 @@ from .validation import ( validate_date_string, require_valid_pin, require_valid_message, + require_valid_payload, require_valid_image, require_valid_rsa_key, require_security_factors, @@ -97,6 +121,7 @@ from .validation import ( from .crypto import ( encrypt_message, decrypt_message, + decrypt_message_text, derive_hybrid_key, derive_pixel_key, hash_photo, @@ -109,6 +134,9 @@ from .steganography import ( extract_from_image, calculate_capacity, get_image_dimensions, + get_image_format, + is_lossless_format, + LOSSLESS_FORMATS, ) from .utils import ( generate_filename, @@ -122,11 +150,12 @@ from .utils import ( ) from datetime import date -from typing import Optional +from pathlib import Path +from typing import Optional, Union def encode( - message: str, + message: Union[str, bytes, FilePayload], reference_photo: bytes, carrier_image: bytes, day_phrase: str, @@ -134,15 +163,16 @@ def encode( 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 into an image. + 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 to hide + 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 @@ -150,6 +180,8 @@ def encode( 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 @@ -159,9 +191,13 @@ def encode( 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_message(message) + require_valid_payload(message) require_valid_image(carrier_image, "Carrier image") require_security_factors(pin, rsa_key_data) @@ -174,7 +210,7 @@ def encode( if date_str is None: date_str = date.today().isoformat() - # Encrypt message + # Encrypt message/file encrypted = encrypt_message( message, reference_photo, day_phrase, date_str, pin, rsa_key_data ) @@ -184,11 +220,13 @@ def encode( reference_photo, day_phrase, date_str, pin, rsa_key_data ) - # Embed in image - stego_data, stats = embed_in_image(carrier_image, encrypted, pixel_key) + # Embed in image (returns extension too) + stego_data, stats, extension = embed_in_image( + carrier_image, encrypted, pixel_key, output_format=output_format + ) - # Generate filename - filename = generate_filename(date_str) + # Generate filename with correct extension + filename = generate_filename(date_str, extension=extension) return EncodeResult( stego_image=stego_data, @@ -200,6 +238,102 @@ def encode( ) +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, @@ -207,15 +341,15 @@ def decode( pin: str = "", rsa_key_data: Optional[bytes] = None, rsa_password: Optional[str] = None, -) -> str: +) -> DecodeResult: """ - Decode a secret message from a stego image. + 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 + 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) @@ -223,7 +357,12 @@ def decode( rsa_password: Password for RSA key if encrypted Returns: - Decrypted message string + 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 @@ -260,21 +399,72 @@ def decode( if not encrypted: raise ExtractionError("Could not extract data. Check your inputs.") - # Decrypt + # 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', @@ -285,6 +475,7 @@ __all__ = [ 'EmbedStats', 'KeyInfo', 'ValidationResult', + 'FilePayload', # Exceptions 'StegasooError', @@ -318,6 +509,8 @@ __all__ = [ # Validation 'validate_pin', 'validate_message', + 'validate_payload', + 'validate_file_payload', 'validate_image', 'validate_rsa_key', 'validate_security_factors', @@ -325,6 +518,7 @@ __all__ = [ 'validate_date_string', 'require_valid_pin', 'require_valid_message', + 'require_valid_payload', 'require_valid_image', 'require_valid_rsa_key', 'require_security_factors', @@ -332,6 +526,7 @@ __all__ = [ # Crypto 'encrypt_message', 'decrypt_message', + 'decrypt_message_text', 'derive_hybrid_key', 'derive_pixel_key', 'hash_photo', @@ -344,6 +539,8 @@ __all__ = [ 'extract_from_image', 'calculate_capacity', 'get_image_dimensions', + 'get_image_format', + 'is_lossless_format', # Utilities 'generate_filename', diff --git a/src/stegasoo/constants.py b/src/stegasoo/constants.py index 87cf449..b28364a 100644 --- a/src/stegasoo/constants.py +++ b/src/stegasoo/constants.py @@ -11,7 +11,7 @@ from pathlib import Path # VERSION # ============================================================================ -__version__ = "2.0.1" +__version__ = "2.1.1" # ============================================================================ # FILE FORMAT @@ -20,6 +20,10 @@ __version__ = "2.0.1" MAGIC_HEADER = b'\x89ST3' FORMAT_VERSION = 3 +# Payload type markers +PAYLOAD_TEXT = 0x01 +PAYLOAD_FILE = 0x02 + # ============================================================================ # CRYPTO PARAMETERS # ============================================================================ @@ -40,9 +44,11 @@ PBKDF2_ITERATIONS = 600000 # INPUT LIMITS # ============================================================================ -MAX_IMAGE_PIXELS = 4_000_000 # ~4 megapixels (2000x2000) -MAX_MESSAGE_SIZE = 50_000 # 50 KB -MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB +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 @@ -78,11 +84,17 @@ DAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 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 + 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: diff --git a/src/stegasoo/crypto.py b/src/stegasoo/crypto.py index 43ae47c..15aa98f 100644 --- a/src/stegasoo/crypto.py +++ b/src/stegasoo/crypto.py @@ -2,13 +2,15 @@ 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 -from typing import Optional +import json +from typing import Optional, Union from PIL import Image from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes @@ -19,7 +21,10 @@ from .constants import ( 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 ) @@ -171,8 +176,112 @@ def derive_pixel_key( 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: str | bytes, + message: Union[str, bytes, FilePayload], photo_data: bytes, day_phrase: str, date_str: str, @@ -180,7 +289,7 @@ def encrypt_message( rsa_key_data: Optional[bytes] = None ) -> bytes: """ - Encrypt message using AES-256-GCM with hybrid key derivation. + Encrypt message or file using AES-256-GCM with hybrid key derivation. Message format: - Magic header (4 bytes) @@ -193,7 +302,7 @@ def encrypt_message( - Ciphertext (variable, padded) Args: - message: Message to encrypt + 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) @@ -211,15 +320,15 @@ def encrypt_message( key = derive_hybrid_key(photo_data, day_phrase, date_str, salt, pin, rsa_key_data) iv = secrets.token_bytes(IV_SIZE) - if isinstance(message, str): - message = message.encode('utf-8') + # 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(message) + padding_len + 255) // 256) * 256 - padding_needed = padded_len - len(message) - padding = secrets.token_bytes(padding_needed - 4) + struct.pack('>I', len(message)) - padded_message = message + padding + 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()) @@ -291,7 +400,7 @@ def decrypt_message( day_phrase: str, pin: str = "", rsa_key_data: Optional[bytes] = None -) -> str: +) -> DecodeResult: """ Decrypt message using the embedded date from the header. @@ -303,7 +412,7 @@ def decrypt_message( rsa_key_data: Optional RSA key bytes Returns: - Decrypted message string + DecodeResult with decrypted content Raises: InvalidHeaderError: If data doesn't have valid Stegasoo header @@ -329,7 +438,11 @@ def decrypt_message( padded_plaintext = decryptor.update(header['ciphertext']) + decryptor.finalize() original_length = struct.unpack('>I', padded_plaintext[-4:])[0] - return padded_plaintext[:original_length].decode('utf-8') + payload_data = padded_plaintext[:original_length] + result = _unpack_payload(payload_data) + result.date_encoded = header['date'] + + return result except Exception as e: raise DecryptionError( @@ -337,6 +450,47 @@ def decrypt_message( ) 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. diff --git a/src/stegasoo/models.py b/src/stegasoo/models.py index 6a9109a..747de21 100644 --- a/src/stegasoo/models.py +++ b/src/stegasoo/models.py @@ -6,7 +6,7 @@ Dataclasses for structured data exchange between modules and frontends. from dataclasses import dataclass, field from datetime import date -from typing import Optional +from typing import Optional, Union @dataclass @@ -43,10 +43,35 @@ class Credentials: 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: str + message: Union[str, bytes, FilePayload] # Text, raw bytes, or file reference_photo: bytes carrier_image: bytes day_phrase: str @@ -90,8 +115,26 @@ class DecodeInput: @dataclass class DecodeResult: """Result of decoding operation.""" - message: str - date_encoded: str + 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 diff --git a/src/stegasoo/steganography.py b/src/stegasoo/steganography.py index 510c8a4..d9f5f12 100644 --- a/src/stegasoo/steganography.py +++ b/src/stegasoo/steganography.py @@ -16,6 +16,43 @@ 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. @@ -83,8 +120,9 @@ def embed_in_image( carrier_data: bytes, encrypted_data: bytes, pixel_key: bytes, - bits_per_channel: int = 1 -) -> tuple[bytes, EmbedStats]: + bits_per_channel: int = 1, + output_format: Optional[str] = None +) -> tuple[bytes, EmbedStats, str]: """ Embed encrypted data in carrier image using LSB steganography. @@ -96,9 +134,11 @@ def embed_in_image( 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 (PNG image bytes, EmbedStats) + Tuple of (image bytes, EmbedStats, file extension) Raises: CapacityError: If carrier is too small @@ -106,6 +146,8 @@ def embed_in_image( """ try: img = Image.open(io.BytesIO(carrier_data)) + input_format = img.format + if img.mode != 'RGB': img = img.convert('RGB') @@ -160,8 +202,15 @@ def embed_in_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, 'PNG') + stego_img.save(output, out_fmt) output.seek(0) stats = EmbedStats( @@ -171,7 +220,7 @@ def embed_in_image( bytes_embedded=len(data_with_len) ) - return output.getvalue(), stats + return output.getvalue(), stats, out_ext except CapacityError: raise @@ -284,3 +333,18 @@ def get_image_dimensions(image_data: bytes) -> tuple[int, int]: """Get image dimensions without loading full image.""" img = Image.open(io.BytesIO(image_data)) return img.size + + +def get_image_format(image_data: bytes) -> Optional[str]: + """Get image format (PIL format string like 'PNG', 'JPEG').""" + try: + img = Image.open(io.BytesIO(image_data)) + return img.format + except Exception: + return None + + +def is_lossless_format(image_data: bytes) -> bool: + """Check if image is in a lossless format suitable for steganography.""" + fmt = get_image_format(image_data) + return fmt is not None and fmt.upper() in LOSSLESS_FORMATS diff --git a/src/stegasoo/utils.py b/src/stegasoo/utils.py index cafbf54..9c4c7a2 100644 --- a/src/stegasoo/utils.py +++ b/src/stegasoo/utils.py @@ -15,15 +15,20 @@ from typing import Optional from .constants import DAY_NAMES -def generate_filename(date_str: Optional[str] = None, prefix: str = "") -> str: +def generate_filename( + date_str: Optional[str] = None, + prefix: str = "", + extension: str = "png" +) -> str: """ Generate a filename for stego images. - Format: {prefix}{random}_{YYYYMMDD}.png + 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 @@ -34,7 +39,10 @@ def generate_filename(date_str: Optional[str] = None, prefix: str = "") -> str: date_compact = date_str.replace('-', '') random_hex = secrets.token_hex(4) - return f"{prefix}{random_hex}_{date_compact}.png" + # 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]: diff --git a/src/stegasoo/validation.py b/src/stegasoo/validation.py index da338aa..9917d77 100644 --- a/src/stegasoo/validation.py +++ b/src/stegasoo/validation.py @@ -5,17 +5,17 @@ Validators for all user inputs with clear error messages. """ import io -from typing import Optional +from typing import Optional, Union from PIL import Image from .constants import ( MIN_PIN_LENGTH, MAX_PIN_LENGTH, - MAX_MESSAGE_SIZE, MAX_IMAGE_PIXELS, MAX_FILE_SIZE, + 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 +from .models import ValidationResult, FilePayload from .exceptions import ( ValidationError, PinValidationError, MessageValidationError, ImageValidationError, KeyValidationError, SecurityFactorError, @@ -61,7 +61,7 @@ def validate_pin(pin: str, required: bool = False) -> ValidationResult: def validate_message(message: str) -> ValidationResult: """ - Validate message content and size. + Validate text message content and size. Args: message: Message text @@ -80,6 +80,81 @@ def validate_message(message: str) -> ValidationResult: 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", @@ -319,6 +394,13 @@ def require_valid_message(message: str) -> None: 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) diff --git a/test_data/holmes_shit_20251228.png b/test_data/holmes_shit_20251228.png new file mode 100644 index 0000000..f123724 Binary files /dev/null and b/test_data/holmes_shit_20251228.png differ diff --git a/test_data/scandal.txt.gz b/test_data/scandal.txt.gz new file mode 100644 index 0000000..bc9d70a Binary files /dev/null and b/test_data/scandal.txt.gz differ diff --git a/test_data/scandal.txt.gz.b64 b/test_data/scandal.txt.gz.b64 new file mode 100644 index 0000000..5b9922b --- /dev/null +++ b/test_data/scandal.txt.gz.b64 @@ -0,0 +1,353 @@ +H4sICKvmUGkCA3NjYW5kYWwudHh0AJV927LjRpLke38Fqh727JqRZ6x3bczaWg9lpUtLp1u3VdVO +WT+CJEhCBwQ4AHgo6o/2O/bH1t0jIjPBU5qxadsdSVUkCCQy4+Lh4fGnP/3pT9V/+L/31Yev3v/4 +9fvvq6cfqy9/+u6bH57e/2ffqd6P8/EyVl8Nfd1XXw+3rvmT/+8/+WL1sd50TTXsq+3Qz00/T//p +N/S/r471eW7G6s//tY//z//ax//Xn+7+V3zku/c/f/zml+op/fHHofpwbMZu2D5X3w3dqZmq6dhU +7VTV3bW+TdWM/7oOp7p/rJ6qY/3SVFPT7YZTdWzqcVcd25Nd6IRlaIcefzxWl36H/1v3t2qY+d99 +fWrwdfwlrtvc/DeabdeeJ/xH3e/sGuexwZXbvp4b/+HjYOvMq0zNb7jIXF3rqeqHGR+oZ/xFtW+6 +mT9m12hOg+6jfm77ah6qbsAt74exehqbvqne77pmfKzed118clrxBuxqQ9/4ndTj3G4vXT12t1V1 +bcamqjfHYcQ1Zl6VD7Idut2K97xtp6baXHATu1M7Ymv4rWzqru63za7CI+0eq+8a3voKyzjXz1ji +eaVnPA3TXJ2bcd9s52ps6mno2/6QF2XYTM34wj861dtji2fQrdp7GTu8AqzH1DT9yu4B66mHHit9 +4tL5dfTuzl3NG8Jbw1vcV1iiutrXHe7/PEwtl0P32Tf8/nQenrX6/K1p2GNzxepMk63cxIte2/mI +6xzaTaOlrKupb7jKH4/NzRfPV4Y3j0fx88LXwmvbIzbjet38tm26jovMv9uN9ZUPzs+8NG1X7Uds +POy0h6niy3tp/EL61a3e5mP15WVOV57HGiu283XFM+Hd8V7marpsj3j+ebzoUew6+G97t8O1r3ZN +126xFXX1PS7T3fDdXy/TjAvOzQnvrOau14ach3SFcdhdtvhWtWsn/D5uC4+wxz9xT9dji589tYcj +3+A4XPmx4bKZq8uZmxbbEj+fT1Td4danSzfjub4dcdt6Y1PT82Vh6dseP3HhJ1cVLl9XW/zgMz+F +p8Wr81fvT3TE767PA95IhTXG0VvZBtFx2nAr4lXxri/jxta91s/hkfrD8njpPnBQL/iGlhJrgJ/B +ycJi3Rrtz1EbXruSdyMrkhYKW7A4ePZ3Wki8tY6rXpzXFTfh7rJph0thLf790ky8F22rE25svD26 +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 diff --git a/tests/test_stegasoo.py b/tests/test_stegasoo.py index 7c9217a..05a7ce7 100644 --- a/tests/test_stegasoo.py +++ b/tests/test_stegasoo.py @@ -22,6 +22,7 @@ from stegasoo import ( decode, DAY_NAMES, ) +from stegasoo.steganography import get_output_format, get_image_format class TestKeygen: @@ -127,19 +128,71 @@ class TestValidation: assert not result.is_valid +class TestOutputFormat: + """Test output format detection and preservation.""" + + def test_png_stays_png(self): + fmt, ext = get_output_format('PNG') + assert fmt == 'PNG' + assert ext == 'png' + + def test_bmp_stays_bmp(self): + fmt, ext = get_output_format('BMP') + assert fmt == 'BMP' + assert ext == 'bmp' + + def test_jpeg_becomes_png(self): + fmt, ext = get_output_format('JPEG') + assert fmt == 'PNG' + assert ext == 'png' + + def test_gif_becomes_png(self): + fmt, ext = get_output_format('GIF') + assert fmt == 'PNG' + assert ext == 'png' + + def test_none_becomes_png(self): + fmt, ext = get_output_format(None) + assert fmt == 'PNG' + assert ext == 'png' + + def test_unknown_becomes_png(self): + fmt, ext = get_output_format('WEBP') + assert fmt == 'PNG' + assert ext == 'png' + + class TestEncodeDecode: """Test encoding and decoding (requires test images).""" @pytest.fixture - def test_image(self): - """Create a simple test image.""" + def png_image(self): + """Create a simple PNG test image.""" from PIL import Image img = Image.new('RGB', (100, 100), color='red') buf = io.BytesIO() img.save(buf, format='PNG') return buf.getvalue() - def test_encode_decode_roundtrip(self, test_image): + @pytest.fixture + def bmp_image(self): + """Create a simple BMP test image.""" + from PIL import Image + img = Image.new('RGB', (100, 100), color='blue') + buf = io.BytesIO() + img.save(buf, format='BMP') + return buf.getvalue() + + @pytest.fixture + def jpeg_image(self): + """Create a simple JPEG test image.""" + from PIL import Image + img = Image.new('RGB', (100, 100), color='green') + buf = io.BytesIO() + img.save(buf, format='JPEG', quality=95) + return buf.getvalue() + + def test_encode_decode_roundtrip(self, png_image): """Test full encode/decode cycle.""" message = "Secret message!" phrase = "apple forest thunder" @@ -147,8 +200,8 @@ class TestEncodeDecode: result = encode( message=message, - reference_photo=test_image, - carrier_image=test_image, + reference_photo=png_image, + carrier_image=png_image, day_phrase=phrase, pin=pin ) @@ -159,19 +212,89 @@ class TestEncodeDecode: decoded = decode( stego_image=result.stego_image, - reference_photo=test_image, + reference_photo=png_image, day_phrase=phrase, pin=pin ) assert decoded == message - def test_wrong_pin_fails(self, test_image): + def test_png_carrier_produces_png(self, png_image): + """Test that PNG carrier produces PNG output.""" + result = encode( + message="Test", + reference_photo=png_image, + carrier_image=png_image, + day_phrase="test phrase here", + pin="123456" + ) + + assert result.filename.endswith('.png') + # Verify actual format + output_format = get_image_format(result.stego_image) + assert output_format == 'PNG' + + def test_bmp_carrier_produces_bmp(self, bmp_image, png_image): + """Test that BMP carrier produces BMP output.""" + result = encode( + message="Test", + reference_photo=png_image, + carrier_image=bmp_image, + day_phrase="test phrase here", + pin="123456" + ) + + assert result.filename.endswith('.bmp') + # Verify actual format + output_format = get_image_format(result.stego_image) + assert output_format == 'BMP' + + def test_jpeg_carrier_produces_png(self, jpeg_image, png_image): + """Test that JPEG carrier produces PNG output (lossy -> lossless).""" + result = encode( + message="Test", + reference_photo=png_image, + carrier_image=jpeg_image, + day_phrase="test phrase here", + pin="123456" + ) + + assert result.filename.endswith('.png') + # Verify actual format + output_format = get_image_format(result.stego_image) + assert output_format == 'PNG' + + def test_bmp_roundtrip(self, bmp_image, png_image): + """Test full encode/decode cycle with BMP.""" + message = "BMP test message!" + phrase = "test phrase words" + pin = "123456" + + result = encode( + message=message, + reference_photo=png_image, + carrier_image=bmp_image, + day_phrase=phrase, + pin=pin + ) + + assert result.filename.endswith('.bmp') + + decoded = decode( + stego_image=result.stego_image, + reference_photo=png_image, + day_phrase=phrase, + pin=pin + ) + + assert decoded == message + + def test_wrong_pin_fails(self, png_image): """Test that wrong PIN fails to decode.""" result = encode( message="Secret", - reference_photo=test_image, - carrier_image=test_image, + reference_photo=png_image, + carrier_image=png_image, day_phrase="test phrase here", pin="123456" ) @@ -179,17 +302,17 @@ class TestEncodeDecode: with pytest.raises(stegasoo.DecryptionError): decode( stego_image=result.stego_image, - reference_photo=test_image, + reference_photo=png_image, day_phrase="test phrase here", pin="654321" # Wrong PIN ) - def test_wrong_phrase_fails(self, test_image): + def test_wrong_phrase_fails(self, png_image): """Test that wrong phrase fails to decode.""" result = encode( message="Secret", - reference_photo=test_image, - carrier_image=test_image, + reference_photo=png_image, + carrier_image=png_image, day_phrase="correct phrase here", pin="123456" ) @@ -197,7 +320,7 @@ class TestEncodeDecode: with pytest.raises(stegasoo.DecryptionError): decode( stego_image=result.stego_image, - reference_photo=test_image, + reference_photo=png_image, day_phrase="wrong phrase here", pin="123456" )