Added file support and increased file limits.

This commit is contained in:
Aaron D. Lee
2025-12-28 03:44:17 -05:00
parent 130835990e
commit 5bd49cb581
16 changed files with 1576 additions and 178 deletions

View File

@@ -3,7 +3,7 @@
Stegasoo REST API Stegasoo REST API
FastAPI-based REST API for steganography operations. FastAPI-based REST API for steganography operations.
Designed for integration with other services and automation. Supports both text messages and file embedding.
""" """
import io import io
@@ -28,6 +28,8 @@ from stegasoo import (
DAY_NAMES, __version__, DAY_NAMES, __version__,
StegasooError, DecryptionError, CapacityError, StegasooError, DecryptionError, CapacityError,
has_argon2, has_argon2,
FilePayload,
MAX_FILE_PAYLOAD_SIZE,
) )
from stegasoo.constants import ( from stegasoo.constants import (
MIN_PIN_LENGTH, MAX_PIN_LENGTH, MIN_PIN_LENGTH, MAX_PIN_LENGTH,
@@ -42,7 +44,7 @@ from stegasoo.constants import (
app = FastAPI( app = FastAPI(
title="Stegasoo API", title="Stegasoo API",
description="Secure steganography with hybrid authentication", description="Secure steganography with hybrid authentication. Supports text messages and file embedding.",
version=__version__, version=__version__,
docs_url="/docs", docs_url="/docs",
redoc_url="/redoc", redoc_url="/redoc",
@@ -79,6 +81,20 @@ class EncodeRequest(BaseModel):
date_str: Optional[str] = None 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): class EncodeResponse(BaseModel):
stego_image_base64: str stego_image_base64: str
filename: str filename: str
@@ -97,7 +113,12 @@ class DecodeRequest(BaseModel):
class DecodeResponse(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): class ImageInfoResponse(BaseModel):
@@ -112,6 +133,7 @@ class StatusResponse(BaseModel):
version: str version: str
has_argon2: bool has_argon2: bool
day_names: list[str] day_names: list[str]
max_payload_kb: int
class ErrorResponse(BaseModel): class ErrorResponse(BaseModel):
@@ -129,7 +151,8 @@ async def root():
return StatusResponse( return StatusResponse(
version=__version__, version=__version__,
has_argon2=has_argon2(), 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) @app.post("/encode", response_model=EncodeResponse)
async def api_encode(request: EncodeRequest): 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. 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') 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) day_of_week = get_day_from_date(result.date_used)
return EncodeResponse( return EncodeResponse(
@@ -217,16 +287,16 @@ async def api_encode(request: EncodeRequest):
@app.post("/decode", response_model=DecodeResponse) @app.post("/decode", response_model=DecodeResponse)
async def api_decode(request: DecodeRequest): 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: try:
stego = base64.b64decode(request.stego_image_base64) stego = base64.b64decode(request.stego_image_base64)
ref_photo = base64.b64decode(request.reference_photo_base64) ref_photo = base64.b64decode(request.reference_photo_base64)
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
message = decode( result = decode(
stego_image=stego, stego_image=stego,
reference_photo=ref_photo, reference_photo=ref_photo,
day_phrase=request.day_phrase, day_phrase=request.day_phrase,
@@ -235,7 +305,18 @@ async def api_decode(request: DecodeRequest):
rsa_password=request.rsa_password 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: except DecryptionError as e:
raise HTTPException(401, "Decryption failed. Check credentials.") raise HTTPException(401, "Decryption failed. Check credentials.")
@@ -247,10 +328,11 @@ async def api_decode(request: DecodeRequest):
@app.post("/encode/multipart") @app.post("/encode/multipart")
async def api_encode_multipart( async def api_encode_multipart(
message: str = Form(...),
day_phrase: str = Form(...), day_phrase: str = Form(...),
reference_photo: UploadFile = File(...), reference_photo: UploadFile = File(...),
carrier: UploadFile = File(...), carrier: UploadFile = File(...),
message: str = Form(""),
payload_file: Optional[UploadFile] = File(None),
pin: str = Form(""), pin: str = Form(""),
rsa_key: Optional[UploadFile] = File(None), rsa_key: Optional[UploadFile] = File(None),
rsa_password: str = Form(""), rsa_password: str = Form(""),
@@ -259,6 +341,7 @@ async def api_encode_multipart(
""" """
Encode using multipart form data (file uploads). 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. Returns the stego image directly as PNG with metadata headers.
""" """
try: try:
@@ -266,8 +349,21 @@ async def api_encode_multipart(
carrier_data = await carrier.read() carrier_data = await carrier.read()
rsa_key_data = await rsa_key.read() if rsa_key else None 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( result = encode(
message=message, message=payload,
reference_photo=ref_data, reference_photo=ref_data,
carrier_image=carrier_data, carrier_image=carrier_data,
day_phrase=day_phrase, day_phrase=day_phrase,
@@ -277,7 +373,6 @@ async def api_encode_multipart(
date_str=date_str if date_str else None 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) day_of_week = get_day_from_date(result.date_used)
return Response( return Response(
@@ -310,13 +405,15 @@ async def api_decode_multipart(
): ):
""" """
Decode using multipart form data (file uploads). Decode using multipart form data (file uploads).
Returns JSON with payload_type indicating text or file.
""" """
try: try:
ref_data = await reference_photo.read() ref_data = await reference_photo.read()
stego_data = await stego_image.read() stego_data = await stego_image.read()
rsa_key_data = await rsa_key.read() if rsa_key else None rsa_key_data = await rsa_key.read() if rsa_key else None
message = decode( result = decode(
stego_image=stego_data, stego_image=stego_data,
reference_photo=ref_data, reference_photo=ref_data,
day_phrase=day_phrase, day_phrase=day_phrase,
@@ -325,7 +422,18 @@ async def api_decode_multipart(
rsa_password=rsa_password if rsa_password else None 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: except DecryptionError:
raise HTTPException(401, "Decryption failed. Check credentials.") raise HTTPException(401, "Decryption failed. Check credentials.")

View File

@@ -20,12 +20,14 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src'))
import stegasoo import stegasoo
from stegasoo import ( from stegasoo import (
encode, decode, generate_credentials, encode, encode_file, decode,
generate_credentials,
export_rsa_key_pem, load_rsa_key, export_rsa_key_pem, load_rsa_key,
validate_image, calculate_capacity, validate_image, calculate_capacity,
get_day_from_date, parse_date_from_filename, get_day_from_date, parse_date_from_filename,
DAY_NAMES, __version__, DAY_NAMES, __version__,
StegasooError, DecryptionError, ExtractionError, StegasooError, DecryptionError, ExtractionError,
FilePayload,
) )
@@ -42,7 +44,7 @@ def cli():
""" """
Stegasoo - Secure steganography with hybrid authentication. 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 \b
• Reference photo (something you have) • Reference photo (something you have)
@@ -170,8 +172,9 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
@cli.command() @cli.command()
@click.option('--ref', '-r', required=True, type=click.Path(exists=True), help='Reference photo') @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('--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', '-m', help='Text message to encode')
@click.option('--message-file', '-f', type=click.Path(exists=True), help='Read message from file') @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('--phrase', '-p', required=True, help='Day phrase')
@click.option('--pin', help='Static PIN') @click.option('--pin', help='Static PIN')
@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file') @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('--output', '-o', type=click.Path(), help='Output file (default: auto-generated)')
@click.option('--date', 'date_str', help='Date override (YYYY-MM-DD)') @click.option('--date', 'date_str', help='Date override (YYYY-MM-DD)')
@click.option('--quiet', '-q', is_flag=True, help='Suppress output except errors') @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. Requires a reference photo, carrier image, and day phrase.
Must provide either --pin or --key (or both). 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 \b
Examples: Examples:
# Text message
stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder" --pin 123456 -m "secret" 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 # Determine what to encode
if message: payload = None
msg = message
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: elif message_file:
msg = Path(message_file).read_text() payload = Path(message_file).read_text()
elif not sys.stdin.isatty(): elif not sys.stdin.isatty():
msg = sys.stdin.read() payload = sys.stdin.read()
else: 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 # Load key if provided
rsa_key_data = None 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() carrier_image = Path(carrier).read_bytes()
result = encode( result = encode(
message=msg, message=payload,
reference_photo=ref_photo, reference_photo=ref_photo,
carrier_image=carrier_image, carrier_image=carrier_image,
day_phrase=phrase, 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('--pin', help='Static PIN')
@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file') @click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file')
@click.option('--key-password', help='RSA key password') @click.option('--key-password', help='RSA key password')
@click.option('--output', '-o', type=click.Path(), help='Save message to file') @click.option('--output', '-o', type=click.Path(), help='Save decoded content to file')
@click.option('--quiet', '-q', is_flag=True, help='Output only the message') @click.option('--quiet', '-q', is_flag=True, help='Output only the content (for text) or suppress messages (for files)')
def decode_cmd(ref, stego, phrase, pin, key, key_password, output, quiet): @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. Must use the same credentials that were used for encoding.
Automatically detects whether content is text or a file.
\b \b
Examples: 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 "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 # Load key if provided
rsa_key_data = None 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() ref_photo = Path(ref).read_bytes()
stego_image = Path(stego).read_bytes() stego_image = Path(stego).read_bytes()
message = decode( result = decode(
stego_image=stego_image, stego_image=stego_image,
reference_photo=ref_photo, reference_photo=ref_photo,
day_phrase=phrase, day_phrase=phrase,
@@ -295,18 +323,42 @@ def decode_cmd(ref, stego, phrase, pin, key, key_password, output, quiet):
rsa_password=key_password, rsa_password=key_password,
) )
if result.is_file:
# File content
if output: if output:
Path(output).write_text(message) out_path = Path(output)
elif result.filename:
out_path = Path(result.filename)
else:
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: if not quiet:
click.secho(f"✓ Decoded successfully!", fg='green') 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}") click.echo(f" Saved to: {output}")
else: else:
if quiet: if quiet:
click.echo(message) click.echo(result.message)
else: else:
click.secho("✓ Decoded successfully!", fg='green') click.secho("✓ Decoded successfully!", fg='green')
click.echo() click.echo()
click.echo(message) click.echo(result.message)
except (DecryptionError, ExtractionError) as e: except (DecryptionError, ExtractionError) as e:
raise click.ClickException(f"Decryption failed: {e}") raise click.ClickException(f"Decryption failed: {e}")

View File

@@ -3,13 +3,14 @@
Stegasoo Web Frontend Stegasoo Web Frontend
Flask-based web UI for steganography operations. 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 io
import sys import sys
import time import time
import secrets import secrets
import mimetypes
from pathlib import Path from pathlib import Path
from datetime import datetime from datetime import datetime
@@ -27,14 +28,17 @@ from stegasoo import (
export_rsa_key_pem, load_rsa_key, export_rsa_key_pem, load_rsa_key,
validate_pin, validate_message, validate_image, validate_pin, validate_message, validate_image,
validate_rsa_key, validate_security_factors, validate_rsa_key, validate_security_factors,
validate_file_payload,
get_today_day, generate_filename, get_today_day, generate_filename,
DAY_NAMES, __version__, DAY_NAMES, __version__,
StegasooError, DecryptionError, CapacityError, StegasooError, DecryptionError, CapacityError,
has_argon2, has_argon2,
FilePayload,
MAX_FILE_PAYLOAD_SIZE,
) )
from stegasoo.constants import ( from stegasoo.constants import (
MAX_MESSAGE_SIZE, MIN_PIN_LENGTH, MAX_PIN_LENGTH, 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 = Flask(__name__)
app.secret_key = secrets.token_hex(32) 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}) # Temporary file storage for sharing (file_id -> {data, timestamp, filename})
TEMP_FILES: dict[str, dict] = {} TEMP_FILES: dict[str, dict] = {}
@@ -67,6 +71,16 @@ def allowed_image(filename: str) -> bool:
return ext in {'png', 'jpg', 'jpeg', 'bmp', 'gif'} 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 # ROUTES
# ============================================================================ # ============================================================================
@@ -164,6 +178,7 @@ def download_key():
@app.route('/encode', methods=['GET', 'POST']) @app.route('/encode', methods=['GET', 'POST'])
def encode_page(): def encode_page():
day_of_week = get_today_day() day_of_week = get_today_day()
max_payload_kb = MAX_FILE_PAYLOAD_SIZE // 1024
if request.method == 'POST': if request.method == 'POST':
try: try:
@@ -171,30 +186,50 @@ def encode_page():
ref_photo = request.files.get('reference_photo') ref_photo = request.files.get('reference_photo')
carrier = request.files.get('carrier') carrier = request.files.get('carrier')
rsa_key_file = request.files.get('rsa_key') rsa_key_file = request.files.get('rsa_key')
payload_file = request.files.get('payload_file')
if not ref_photo or not carrier: if not ref_photo or not carrier:
flash('Both reference photo and carrier image are required', 'error') 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): if not allowed_image(ref_photo.filename) or not allowed_image(carrier.filename):
flash('Invalid file type. Use PNG, JPG, or BMP', 'error') 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 # Get form data
message = request.form.get('message', '') message = request.form.get('message', '')
day_phrase = request.form.get('day_phrase', '') day_phrase = request.form.get('day_phrase', '')
pin = request.form.get('pin', '').strip() pin = request.form.get('pin', '').strip()
rsa_password = request.form.get('rsa_password', '') rsa_password = request.form.get('rsa_password', '')
payload_type = request.form.get('payload_type', 'text')
# Validate message # 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) result = validate_message(message)
if not result.is_valid: if not result.is_valid:
flash(result.error_message, 'error') 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)
payload = message
if not day_phrase: if not day_phrase:
flash('Day phrase is required', 'error') 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 # Read files
ref_data = ref_photo.read() ref_data = ref_photo.read()
@@ -205,27 +240,27 @@ def encode_page():
result = validate_security_factors(pin, rsa_key_data) result = validate_security_factors(pin, rsa_key_data)
if not result.is_valid: if not result.is_valid:
flash(result.error_message, 'error') 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 # Validate PIN if provided
if pin: if pin:
result = validate_pin(pin) result = validate_pin(pin)
if not result.is_valid: if not result.is_valid:
flash(result.error_message, 'error') 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 # Validate RSA key if provided
if rsa_key_data: if rsa_key_data:
result = validate_rsa_key(rsa_key_data, rsa_password if rsa_password else None) result = validate_rsa_key(rsa_key_data, rsa_password if rsa_password else None)
if not result.is_valid: if not result.is_valid:
flash(result.error_message, 'error') 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 # Validate carrier image
result = validate_image(carrier_data, "Carrier image") result = validate_image(carrier_data, "Carrier image")
if not result.is_valid: if not result.is_valid:
flash(result.error_message, 'error') 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 # Get date
client_date = request.form.get('client_date', '').strip() client_date = request.form.get('client_date', '').strip()
@@ -236,7 +271,7 @@ def encode_page():
# Encode # Encode
encode_result = encode( encode_result = encode(
message=message, message=payload,
reference_photo=ref_data, reference_photo=ref_data,
carrier_image=carrier_data, carrier_image=carrier_data,
day_phrase=day_phrase, day_phrase=day_phrase,
@@ -259,15 +294,15 @@ def encode_page():
except CapacityError as e: except CapacityError as e:
flash(str(e), 'error') 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: except StegasooError as e:
flash(str(e), 'error') 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: except Exception as e:
flash(f'Error: {e}', 'error') 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/<file_id>') @app.route('/encode/result/<file_id>')
@@ -299,7 +334,7 @@ def encode_download(file_id):
@app.route('/encode/file/<file_id>') @app.route('/encode/file/<file_id>')
def encode_file(file_id): def encode_file_route(file_id):
"""Serve file for Web Share API.""" """Serve file for Web Share API."""
if file_id not in TEMP_FILES: if file_id not in TEMP_FILES:
return "Not found", 404 return "Not found", 404
@@ -368,7 +403,7 @@ def decode_page():
return render_template('decode.html') return render_template('decode.html')
# Decode # Decode
message = decode( decode_result = decode(
stego_image=stego_data, stego_image=stego_data,
reference_photo=ref_data, reference_photo=ref_data,
day_phrase=day_phrase, day_phrase=day_phrase,
@@ -377,7 +412,29 @@ def decode_page():
rsa_password=rsa_password if rsa_password else None 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: except DecryptionError:
flash('Decryption failed. Check your phrase, PIN, RSA key, and reference photo.', 'error') 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') return render_template('decode.html')
@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') @app.route('/about')
def 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
)
# ============================================================================ # ============================================================================

View File

@@ -7,10 +7,11 @@
<div class="col-lg-8"> <div class="col-lg-8">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0"><i class="bi bi-unlock-fill me-2"></i>Decode Secret Message</h5> <h5 class="mb-0"><i class="bi bi-unlock-fill me-2"></i>Decode Secret Message or File</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
{% if decoded_message %} {% if decoded_message %}
<!-- Text Message Result -->
<div class="alert alert-success"> <div class="alert alert-success">
<h6><i class="bi bi-check-circle me-2"></i>Message Decrypted Successfully!</h6> <h6><i class="bi bi-check-circle me-2"></i>Message Decrypted Successfully!</h6>
</div> </div>
@@ -23,17 +24,40 @@
</button> </button>
</div> </div>
i<!--then<div class="mb-4"> <a href="/decode" class="btn btn-outline-light w-100 mt-3">
<label class="form-label text-muted">Decoded Message:</label> <i class="bi bi-arrow-repeat me-2"></i>Decode Another
<div class="alert-message">{{ decoded_message }}</div> </a>
</div>-->
{% elif decoded_file %}
<!-- File Result -->
<div class="alert alert-success">
<h6><i class="bi bi-check-circle me-2"></i>File Decrypted Successfully!</h6>
</div>
<div class="text-center mb-4">
<i class="bi bi-file-earmark-check text-success" style="font-size: 4rem;"></i>
<h5 class="mt-3">{{ filename }}</h5>
<p class="text-muted mb-1">{{ file_size }}</p>
{% if mime_type %}
<small class="text-muted">Type: {{ mime_type }}</small>
{% endif %}
</div>
<a href="{{ url_for('decode_download', file_id=file_id) }}" class="btn btn-primary btn-lg w-100 mb-3">
<i class="bi bi-download me-2"></i>Download File
</a>
<div class="alert alert-warning small">
<i class="bi bi-clock me-1"></i>
<strong>File expires in 5 minutes.</strong> Download now.
</div>
<a href="/decode" class="btn btn-outline-light w-100"> <a href="/decode" class="btn btn-outline-light w-100">
<i class="bi bi-arrow-repeat me-2"></i>Decode Another Message <i class="bi bi-arrow-repeat me-2"></i>Decode Another
</a> </a>
{% else %} {% else %}
<!-- Decode Form -->
<form method="POST" enctype="multipart/form-data" id="decodeForm"> <form method="POST" enctype="multipart/form-data" id="decodeForm">
<div class="row"> <div class="row">
<div class="col-md-6 mb-3"> <div class="col-md-6 mb-3">
@@ -66,7 +90,7 @@
<img class="drop-zone-preview d-none"> <img class="drop-zone-preview d-none">
</div> </div>
<div class="form-text"> <div class="form-text">
The image containing the hidden message The image containing the hidden message/file
</div> </div>
</div> </div>
</div> </div>
@@ -130,7 +154,7 @@
</div> </div>
<button type="submit" class="btn btn-primary btn-lg w-100" id="decodeBtn"> <button type="submit" class="btn btn-primary btn-lg w-100" id="decodeBtn">
<i class="bi bi-unlock me-2"></i>Decode Message <i class="bi bi-unlock me-2"></i>Decode
</button> </button>
</form> </form>
@@ -138,6 +162,7 @@
</div> </div>
</div> </div>
{% if not decoded_message and not decoded_file %}
<div class="card mt-4"> <div class="card mt-4">
<div class="card-body"> <div class="card-body">
<h6 class="text-muted mb-3"><i class="bi bi-question-circle me-2"></i>Troubleshooting</h6> <h6 class="text-muted mb-3"><i class="bi bi-question-circle me-2"></i>Troubleshooting</h6>
@@ -165,6 +190,7 @@
</ul> </ul>
</div> </div>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
{% endblock %} {% endblock %}
@@ -209,7 +235,7 @@ function updateDayLabel(dayName) {
} }
} }
// 1. PIN Toggle // PIN Toggle
document.getElementById('togglePin')?.addEventListener('click', function() { document.getElementById('togglePin')?.addEventListener('click', function() {
const input = document.getElementById('pinInput'); const input = document.getElementById('pinInput');
const icon = this.querySelector('i'); 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) { document.addEventListener('paste', function(e) {
// Only run if the form exists (we are not on the success page)
if (!document.getElementById('decodeForm')) return; if (!document.getElementById('decodeForm')) return;
const items = e.clipboardData.items; const items = e.clipboardData.items;
@@ -232,7 +257,6 @@ document.addEventListener('paste', function(e) {
if (items[i].type.indexOf("image") !== -1) { if (items[i].type.indexOf("image") !== -1) {
const blob = items[i].getAsFile(); 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 stegoInput = document.querySelector('input[name="stego_image"]');
const refInput = document.querySelector('input[name="reference_photo"]'); const refInput = document.querySelector('input[name="reference_photo"]');

View File

@@ -7,7 +7,7 @@
<div class="col-lg-8"> <div class="col-lg-8">
<div class="card"> <div class="card">
<div class="card-header"> <div class="card-header">
<h5 class="mb-0"><i class="bi bi-lock-fill me-2"></i>Encode Secret Message</h5> <h5 class="mb-0"><i class="bi bi-lock-fill me-2"></i>Encode Secret Message or File</h5>
</div> </div>
<div class="card-body"> <div class="card-body">
<form method="POST" enctype="multipart/form-data" id="encodeForm"> <form method="POST" enctype="multipart/form-data" id="encodeForm">
@@ -49,15 +49,34 @@
</div> </div>
</div> </div>
<!-- Payload Type Selector -->
<div class="mb-3"> <div class="mb-3">
<label class="form-label">
<i class="bi bi-box me-1"></i> What to Encode
</label>
<div class="btn-group w-100" role="group">
<input type="radio" class="btn-check" name="payload_type" id="payloadText" value="text" checked>
<label class="btn btn-outline-primary" for="payloadText">
<i class="bi bi-chat-left-text me-1"></i> Text Message
</label>
<input type="radio" class="btn-check" name="payload_type" id="payloadFile" value="file">
<label class="btn btn-outline-primary" for="payloadFile">
<i class="bi bi-file-earmark me-1"></i> File
</label>
</div>
</div>
<!-- Text Message Input -->
<div class="mb-3" id="textPayloadSection">
<label class="form-label"> <label class="form-label">
<i class="bi bi-chat-left-text me-1"></i> Secret Message <i class="bi bi-chat-left-text me-1"></i> Secret Message
</label> </label>
<textarea name="message" class="form-control" rows="4" id="messageInput" <textarea name="message" class="form-control" rows="4" id="messageInput"
placeholder="Enter your secret message here..." required></textarea> placeholder="Enter your secret message here..."></textarea>
<div class="d-flex justify-content-between form-text"> <div class="d-flex justify-content-between form-text">
<span> <span>
<span id="charCount">0</span> / 50,000 characters <span id="charCount">0</span> / 250,000 characters
<span id="charWarning" class="text-warning d-none ms-2"> <span id="charWarning" class="text-warning d-none ms-2">
<i class="bi bi-exclamation-triangle"></i> Getting long! <i class="bi bi-exclamation-triangle"></i> Getting long!
</span> </span>
@@ -66,6 +85,29 @@
</div> </div>
</div> </div>
<!-- File Upload Input -->
<div class="mb-3 d-none" id="filePayloadSection">
<label class="form-label">
<i class="bi bi-file-earmark me-1"></i> File to Embed
</label>
<div class="drop-zone" id="payloadDropZone">
<input type="file" name="payload_file" id="payloadFileInput">
<div class="drop-zone-label" id="payloadDropLabel">
<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>
<span class="text-muted">Drop any file or click to browse</span>
<div class="small text-muted mt-1">Max {{ max_payload_kb }} KB</div>
</div>
</div>
<div class="form-text">
Supports any file type: PDF, ZIP, documents, etc.
</div>
<div id="fileInfo" class="d-none mt-2 p-2 bg-dark rounded">
<i class="bi bi-file-earmark-check text-success me-2"></i>
<span id="fileInfoName"></span>
<span class="text-muted">(<span id="fileInfoSize"></span>)</span>
</div>
</div>
<div class="mb-3"> <div class="mb-3">
<label class="form-label" id="dayPhraseLabel"> <label class="form-label" id="dayPhraseLabel">
<i class="bi bi-chat-quote me-1"></i> {{ day_of_week }}'s Phrase <i class="bi bi-chat-quote me-1"></i> {{ day_of_week }}'s Phrase
@@ -121,7 +163,7 @@
</div> </div>
<button type="submit" class="btn btn-primary btn-lg w-100" id="encodeBtn"> <button type="submit" class="btn btn-primary btn-lg w-100" id="encodeBtn">
<i class="bi bi-lock me-2"></i>Encode Message <i class="bi bi-lock me-2"></i>Encode
</button> </button>
</form> </form>
@@ -146,8 +188,8 @@
<i class="bi bi-info-circle me-1"></i> <i class="bi bi-info-circle me-1"></i>
<strong>Limits:</strong> <strong>Limits:</strong>
Carrier image max ~4 megapixels (2000×2000). Carrier image max ~4 megapixels (2000×2000).
Files max 5MB each. Files max 10MB upload.
Message max 50KB. Payload max {{ max_payload_kb }} KB.
</div> </div>
</div> </div>
</div> </div>
@@ -177,6 +219,57 @@ if (dateInput) {
dateInput.value = localDate; 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 = `<i class="bi bi-check-circle text-success fs-3 d-block mb-2"></i><span>${file.name}</span>`;
} else {
fileInfo.classList.add('d-none');
payloadDropLabel.innerHTML = `<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i><span class="text-muted">Drop any file or click to browse</span><div class="small text-muted mt-1">Max {{ max_payload_kb }} KB</div>`;
}
});
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 // Show RSA password field when key is selected
const rsaKeyInput = document.getElementById('rsaKeyInput'); const rsaKeyInput = document.getElementById('rsaKeyInput');
const rsaPasswordGroup = document.getElementById('rsaPasswordGroup'); const rsaPasswordGroup = document.getElementById('rsaPasswordGroup');
@@ -192,12 +285,11 @@ document.getElementById('encodeForm').addEventListener('submit', function(e) {
btn.disabled = true; btn.disabled = true;
}); });
// Character counter // Character counter for text
const messageInput = document.getElementById('messageInput');
const charCount = document.getElementById('charCount'); const charCount = document.getElementById('charCount');
const charWarning = document.getElementById('charWarning'); const charWarning = document.getElementById('charWarning');
const charPercent = document.getElementById('charPercent'); const charPercent = document.getElementById('charPercent');
const maxChars = 50000; const maxChars = 250000;
messageInput.addEventListener('input', function() { messageInput.addEventListener('input', function() {
const len = this.value.length; const len = this.value.length;
@@ -210,11 +302,12 @@ messageInput.addEventListener('input', function() {
charCount.classList.toggle('text-danger', len > maxChars * 0.95); 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 => { document.querySelectorAll('.drop-zone').forEach(zone => {
const input = zone.querySelector('input[type="file"]'); const input = zone.querySelector('input[type="file"]');
const label = zone.querySelector('.drop-zone-label'); const label = zone.querySelector('.drop-zone-label');
const preview = zone.querySelector('.drop-zone-preview'); const preview = zone.querySelector('.drop-zone-preview');
const isPayloadZone = zone.id === 'payloadDropZone';
['dragenter', 'dragover'].forEach(evt => { ['dragenter', 'dragover'].forEach(evt => {
zone.addEventListener(evt, e => { zone.addEventListener(evt, e => {
@@ -233,29 +326,38 @@ document.querySelectorAll('.drop-zone').forEach(zone => {
zone.addEventListener('drop', e => { zone.addEventListener('drop', e => {
if (e.dataTransfer.files.length) { if (e.dataTransfer.files.length) {
input.files = e.dataTransfer.files; input.files = e.dataTransfer.files;
input.dispatchEvent(new Event('change'));
if (!isPayloadZone) {
showPreview(e.dataTransfer.files[0]); showPreview(e.dataTransfer.files[0]);
} }
}
}); });
if (!isPayloadZone) {
input.addEventListener('change', function() { input.addEventListener('change', function() {
if (this.files && this.files[0]) { if (this.files && this.files[0]) {
showPreview(this.files[0]); showPreview(this.files[0]);
} }
}); });
}
function showPreview(file) { function showPreview(file) {
if (!file.type.startsWith('image/')) return; if (!file.type.startsWith('image/')) return;
const reader = new FileReader(); const reader = new FileReader();
reader.onload = e => { reader.onload = e => {
if (preview) {
preview.src = e.target.result; preview.src = e.target.result;
preview.classList.remove('d-none'); preview.classList.remove('d-none');
}
label.innerHTML = '<i class="bi bi-check-circle text-success me-1"></i>' + file.name; label.innerHTML = '<i class="bi bi-check-circle text-success me-1"></i>' + file.name;
}; };
reader.readAsDataURL(file); reader.readAsDataURL(file);
} }
}); });
// 1. PIN Toggle Logic
// PIN Toggle Logic
document.getElementById('togglePin').addEventListener('click', function() { document.getElementById('togglePin').addEventListener('click', function() {
const input = document.getElementById('pinInput'); const input = document.getElementById('pinInput');
const icon = this.querySelector('i'); 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() { function checkDuplicateFiles() {
const refInput = document.querySelector('input[name="reference_photo"]'); const refInput = document.querySelector('input[name="reference_photo"]');
const carInput = document.querySelector('input[name="carrier"]'); const carInput = document.querySelector('input[name="carrier"]');
if (refInput.files[0] && carInput.files[0]) { 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 && if (refInput.files[0].name === carInput.files[0].name &&
refInput.files[0].size === carInput.files[0].size) { refInput.files[0].size === carInput.files[0].size) {
alert("Security Warning: You cannot use the same image for both Reference and Carrier!"); 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.getElementById('carrierPreview').classList.add('d-none');
document.querySelector('#carrierDropZone .drop-zone-label').innerHTML = document.querySelector('#carrierDropZone .drop-zone-label').innerHTML =
'<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>' + '<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>' +
@@ -289,14 +390,13 @@ function checkDuplicateFiles() {
document.querySelector('input[name="reference_photo"]').addEventListener('change', checkDuplicateFiles); document.querySelector('input[name="reference_photo"]').addEventListener('change', checkDuplicateFiles);
document.querySelector('input[name="carrier"]').addEventListener('change', checkDuplicateFiles); document.querySelector('input[name="carrier"]').addEventListener('change', checkDuplicateFiles);
// 3. Paste from Clipboard // Paste from Clipboard
document.addEventListener('paste', function(e) { document.addEventListener('paste', function(e) {
const items = e.clipboardData.items; const items = e.clipboardData.items;
for (let i = 0; i < items.length; i++) { for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image") !== -1) { if (items[i].type.indexOf("image") !== -1) {
const blob = items[i].getAsFile(); const blob = items[i].getAsFile();
// Priority: Fill Carrier first, if empty. Else fill Reference.
const carrierInput = document.querySelector('input[name="carrier"]'); const carrierInput = document.querySelector('input[name="carrier"]');
const refInput = document.querySelector('input[name="reference_photo"]'); const refInput = document.querySelector('input[name="reference_photo"]');
@@ -307,7 +407,7 @@ document.addEventListener('paste', function(e) {
targetInput.files = container.files; targetInput.files = container.files;
targetInput.dispatchEvent(new Event('change')); targetInput.dispatchEvent(new Event('change'));
break; // Only paste one image at a time break;
} }
} }
}); });

View File

@@ -1,10 +1,10 @@
""" """
Stegasoo - Secure Steganography Library 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. hybrid photo + passphrase + PIN authentication.
Basic Usage: Basic Usage - Text Message:
from stegasoo import encode, decode, generate_credentials from stegasoo import encode, decode, generate_credentials
# Generate credentials # Generate credentials
@@ -30,16 +30,36 @@ Basic Usage:
f.write(result.stego_image) f.write(result.stego_image)
# Decode a message # Decode a message
message = decode( decoded = decode(
stego_image=result.stego_image, stego_image=result.stego_image,
reference_photo=ref_photo, reference_photo=ref_photo,
day_phrase="apple forest thunder", day_phrase="apple forest thunder",
pin="123456" 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 ( from .models import (
Credentials, Credentials,
EncodeInput, EncodeInput,
@@ -49,6 +69,7 @@ from .models import (
EmbedStats, EmbedStats,
KeyInfo, KeyInfo,
ValidationResult, ValidationResult,
FilePayload,
) )
from .exceptions import ( from .exceptions import (
StegasooError, StegasooError,
@@ -83,6 +104,8 @@ from .keygen import (
from .validation import ( from .validation import (
validate_pin, validate_pin,
validate_message, validate_message,
validate_payload,
validate_file_payload,
validate_image, validate_image,
validate_rsa_key, validate_rsa_key,
validate_security_factors, validate_security_factors,
@@ -90,6 +113,7 @@ from .validation import (
validate_date_string, validate_date_string,
require_valid_pin, require_valid_pin,
require_valid_message, require_valid_message,
require_valid_payload,
require_valid_image, require_valid_image,
require_valid_rsa_key, require_valid_rsa_key,
require_security_factors, require_security_factors,
@@ -97,6 +121,7 @@ from .validation import (
from .crypto import ( from .crypto import (
encrypt_message, encrypt_message,
decrypt_message, decrypt_message,
decrypt_message_text,
derive_hybrid_key, derive_hybrid_key,
derive_pixel_key, derive_pixel_key,
hash_photo, hash_photo,
@@ -109,6 +134,9 @@ from .steganography import (
extract_from_image, extract_from_image,
calculate_capacity, calculate_capacity,
get_image_dimensions, get_image_dimensions,
get_image_format,
is_lossless_format,
LOSSLESS_FORMATS,
) )
from .utils import ( from .utils import (
generate_filename, generate_filename,
@@ -122,11 +150,12 @@ from .utils import (
) )
from datetime import date from datetime import date
from typing import Optional from pathlib import Path
from typing import Optional, Union
def encode( def encode(
message: str, message: Union[str, bytes, FilePayload],
reference_photo: bytes, reference_photo: bytes,
carrier_image: bytes, carrier_image: bytes,
day_phrase: str, day_phrase: str,
@@ -134,15 +163,16 @@ def encode(
rsa_key_data: Optional[bytes] = None, rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None, rsa_password: Optional[str] = None,
date_str: Optional[str] = None, date_str: Optional[str] = None,
output_format: Optional[str] = None,
) -> EncodeResult: ) -> EncodeResult:
""" """
Encode a secret message into an image. Encode a secret message or file into an image.
High-level convenience function that handles validation, High-level convenience function that handles validation,
encryption, and embedding in one call. encryption, and embedding in one call.
Args: Args:
message: Secret message to hide message: Secret message (str), raw bytes, or FilePayload to hide
reference_photo: Shared reference photo bytes reference_photo: Shared reference photo bytes
carrier_image: Image to hide message in carrier_image: Image to hide message in
day_phrase: Today's passphrase day_phrase: Today's passphrase
@@ -150,6 +180,8 @@ def encode(
rsa_key_data: RSA private key PEM bytes (optional if using PIN) rsa_key_data: RSA private key PEM bytes (optional if using PIN)
rsa_password: Password for RSA key if encrypted rsa_password: Password for RSA key if encrypted
date_str: Date string YYYY-MM-DD (defaults to today) 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: Returns:
EncodeResult with stego image and metadata EncodeResult with stego image and metadata
@@ -159,9 +191,13 @@ def encode(
SecurityFactorError: If no PIN or RSA key provided SecurityFactorError: If no PIN or RSA key provided
CapacityError: If carrier is too small CapacityError: If carrier is too small
EncryptionError: If encryption fails 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 # Validate inputs
require_valid_message(message) require_valid_payload(message)
require_valid_image(carrier_image, "Carrier image") require_valid_image(carrier_image, "Carrier image")
require_security_factors(pin, rsa_key_data) require_security_factors(pin, rsa_key_data)
@@ -174,7 +210,7 @@ def encode(
if date_str is None: if date_str is None:
date_str = date.today().isoformat() date_str = date.today().isoformat()
# Encrypt message # 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
) )
@@ -184,11 +220,13 @@ def encode(
reference_photo, day_phrase, date_str, pin, rsa_key_data reference_photo, day_phrase, date_str, pin, rsa_key_data
) )
# Embed in image # Embed in image (returns extension too)
stego_data, stats = embed_in_image(carrier_image, encrypted, pixel_key) stego_data, stats, extension = embed_in_image(
carrier_image, encrypted, pixel_key, output_format=output_format
)
# Generate filename # Generate filename with correct extension
filename = generate_filename(date_str) filename = generate_filename(date_str, extension=extension)
return EncodeResult( return EncodeResult(
stego_image=stego_data, 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( def decode(
stego_image: bytes, stego_image: bytes,
reference_photo: bytes, reference_photo: bytes,
@@ -207,15 +341,15 @@ def decode(
pin: str = "", pin: str = "",
rsa_key_data: Optional[bytes] = None, rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = 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 High-level convenience function that handles extraction
and decryption in one call. and decryption in one call.
Args: Args:
stego_image: Image containing hidden message stego_image: Image containing hidden message/file
reference_photo: Shared reference photo bytes reference_photo: Shared reference photo bytes
day_phrase: Passphrase for the day message was encoded day_phrase: Passphrase for the day message was encoded
pin: Static PIN (if used during encoding) pin: Static PIN (if used during encoding)
@@ -223,7 +357,12 @@ def decode(
rsa_password: Password for RSA key if encrypted rsa_password: Password for RSA key if encrypted
Returns: 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: Raises:
ValidationError: If inputs are invalid ValidationError: If inputs are invalid
@@ -260,21 +399,72 @@ def decode(
if not encrypted: if not encrypted:
raise ExtractionError("Could not extract data. Check your inputs.") 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) 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__ = [ __all__ = [
# Version # Version
'__version__', '__version__',
# High-level API # High-level API
'encode', 'encode',
'encode_file',
'encode_bytes',
'decode', 'decode',
'decode_text',
'generate_credentials', 'generate_credentials',
# Constants # Constants
'DAY_NAMES', 'DAY_NAMES',
'LOSSLESS_FORMATS',
'MAX_MESSAGE_SIZE',
'MAX_FILE_PAYLOAD_SIZE',
# Models # Models
'Credentials', 'Credentials',
@@ -285,6 +475,7 @@ __all__ = [
'EmbedStats', 'EmbedStats',
'KeyInfo', 'KeyInfo',
'ValidationResult', 'ValidationResult',
'FilePayload',
# Exceptions # Exceptions
'StegasooError', 'StegasooError',
@@ -318,6 +509,8 @@ __all__ = [
# Validation # Validation
'validate_pin', 'validate_pin',
'validate_message', 'validate_message',
'validate_payload',
'validate_file_payload',
'validate_image', 'validate_image',
'validate_rsa_key', 'validate_rsa_key',
'validate_security_factors', 'validate_security_factors',
@@ -325,6 +518,7 @@ __all__ = [
'validate_date_string', 'validate_date_string',
'require_valid_pin', 'require_valid_pin',
'require_valid_message', 'require_valid_message',
'require_valid_payload',
'require_valid_image', 'require_valid_image',
'require_valid_rsa_key', 'require_valid_rsa_key',
'require_security_factors', 'require_security_factors',
@@ -332,6 +526,7 @@ __all__ = [
# Crypto # Crypto
'encrypt_message', 'encrypt_message',
'decrypt_message', 'decrypt_message',
'decrypt_message_text',
'derive_hybrid_key', 'derive_hybrid_key',
'derive_pixel_key', 'derive_pixel_key',
'hash_photo', 'hash_photo',
@@ -344,6 +539,8 @@ __all__ = [
'extract_from_image', 'extract_from_image',
'calculate_capacity', 'calculate_capacity',
'get_image_dimensions', 'get_image_dimensions',
'get_image_format',
'is_lossless_format',
# Utilities # Utilities
'generate_filename', 'generate_filename',

View File

@@ -11,7 +11,7 @@ from pathlib import Path
# VERSION # VERSION
# ============================================================================ # ============================================================================
__version__ = "2.0.1" __version__ = "2.1.1"
# ============================================================================ # ============================================================================
# FILE FORMAT # FILE FORMAT
@@ -20,6 +20,10 @@ __version__ = "2.0.1"
MAGIC_HEADER = b'\x89ST3' MAGIC_HEADER = b'\x89ST3'
FORMAT_VERSION = 3 FORMAT_VERSION = 3
# Payload type markers
PAYLOAD_TEXT = 0x01
PAYLOAD_FILE = 0x02
# ============================================================================ # ============================================================================
# CRYPTO PARAMETERS # CRYPTO PARAMETERS
# ============================================================================ # ============================================================================
@@ -40,9 +44,11 @@ PBKDF2_ITERATIONS = 600000
# INPUT LIMITS # INPUT LIMITS
# ============================================================================ # ============================================================================
MAX_IMAGE_PIXELS = 4_000_000 # ~4 megapixels (2000x2000) MAX_IMAGE_PIXELS = 16_000_000 # ~16 megapixels (4000x4000)
MAX_MESSAGE_SIZE = 50_000 # 50 KB MAX_MESSAGE_SIZE = 250_000 # 250 KB (text messages)
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB 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 MIN_PIN_LENGTH = 6
MAX_PIN_LENGTH = 9 MAX_PIN_LENGTH = 9
@@ -78,11 +84,17 @@ DAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
def get_data_dir() -> Path: def get_data_dir() -> Path:
"""Get the data directory path.""" """Get the data directory path."""
# Check multiple locations # Check multiple locations
# From src/stegasoo/constants.py:
# .parent = src/stegasoo/
# .parent.parent = src/
# .parent.parent.parent = project root (where data/ lives)
candidates = [ 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(__file__).parent / 'data', # Installed package
Path('/app/data'), # Docker Path('/app/data'), # Docker
Path.cwd() / 'data', # Current directory 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: for path in candidates:

View File

@@ -2,13 +2,15 @@
Stegasoo Cryptographic Functions Stegasoo Cryptographic Functions
Key derivation, encryption, and decryption using AES-256-GCM. Key derivation, encryption, and decryption using AES-256-GCM.
Supports both text messages and binary file payloads.
""" """
import io import io
import hashlib import hashlib
import secrets import secrets
import struct import struct
from typing import Optional import json
from typing import Optional, Union
from PIL import Image from PIL import Image
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
@@ -19,7 +21,10 @@ from .constants import (
SALT_SIZE, IV_SIZE, TAG_SIZE, SALT_SIZE, IV_SIZE, TAG_SIZE,
ARGON2_TIME_COST, ARGON2_MEMORY_COST, ARGON2_PARALLELISM, ARGON2_TIME_COST, ARGON2_MEMORY_COST, ARGON2_PARALLELISM,
PBKDF2_ITERATIONS, PBKDF2_ITERATIONS,
PAYLOAD_TEXT, PAYLOAD_FILE,
MAX_FILENAME_LENGTH,
) )
from .models import FilePayload, DecodeResult
from .exceptions import ( from .exceptions import (
EncryptionError, DecryptionError, KeyDerivationError, InvalidHeaderError EncryptionError, DecryptionError, KeyDerivationError, InvalidHeaderError
) )
@@ -171,8 +176,112 @@ def derive_pixel_key(
return hashlib.sha256(material + b"pixel_selection").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( def encrypt_message(
message: str | bytes, message: Union[str, bytes, FilePayload],
photo_data: bytes, photo_data: bytes,
day_phrase: str, day_phrase: str,
date_str: str, date_str: str,
@@ -180,7 +289,7 @@ def encrypt_message(
rsa_key_data: Optional[bytes] = None rsa_key_data: Optional[bytes] = None
) -> bytes: ) -> 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: Message format:
- Magic header (4 bytes) - Magic header (4 bytes)
@@ -193,7 +302,7 @@ def encrypt_message(
- Ciphertext (variable, padded) - Ciphertext (variable, padded)
Args: Args:
message: Message to encrypt message: Message string, raw bytes, or FilePayload to encrypt
photo_data: Reference photo bytes photo_data: Reference photo bytes
day_phrase: The day's phrase day_phrase: The day's phrase
date_str: Date string (YYYY-MM-DD) 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) key = derive_hybrid_key(photo_data, day_phrase, date_str, salt, pin, rsa_key_data)
iv = secrets.token_bytes(IV_SIZE) iv = secrets.token_bytes(IV_SIZE)
if isinstance(message, str): # Pack payload with type marker
message = message.encode('utf-8') packed_payload, _ = _pack_payload(message)
# Random padding to hide message length # Random padding to hide message length
padding_len = secrets.randbelow(256) + 64 padding_len = secrets.randbelow(256) + 64
padded_len = ((len(message) + padding_len + 255) // 256) * 256 padded_len = ((len(packed_payload) + padding_len + 255) // 256) * 256
padding_needed = padded_len - len(message) padding_needed = padded_len - len(packed_payload)
padding = secrets.token_bytes(padding_needed - 4) + struct.pack('>I', len(message)) padding = secrets.token_bytes(padding_needed - 4) + struct.pack('>I', len(packed_payload))
padded_message = message + padding padded_message = packed_payload + padding
# Encrypt with AES-256-GCM # Encrypt with AES-256-GCM
cipher = Cipher(algorithms.AES(key), modes.GCM(iv), backend=default_backend()) cipher = Cipher(algorithms.AES(key), modes.GCM(iv), backend=default_backend())
@@ -291,7 +400,7 @@ def decrypt_message(
day_phrase: str, day_phrase: str,
pin: str = "", pin: str = "",
rsa_key_data: Optional[bytes] = None rsa_key_data: Optional[bytes] = None
) -> str: ) -> DecodeResult:
""" """
Decrypt message using the embedded date from the header. Decrypt message using the embedded date from the header.
@@ -303,7 +412,7 @@ def decrypt_message(
rsa_key_data: Optional RSA key bytes rsa_key_data: Optional RSA key bytes
Returns: Returns:
Decrypted message string DecodeResult with decrypted content
Raises: Raises:
InvalidHeaderError: If data doesn't have valid Stegasoo header InvalidHeaderError: If data doesn't have valid Stegasoo header
@@ -329,7 +438,11 @@ def decrypt_message(
padded_plaintext = decryptor.update(header['ciphertext']) + decryptor.finalize() padded_plaintext = decryptor.update(header['ciphertext']) + decryptor.finalize()
original_length = struct.unpack('>I', padded_plaintext[-4:])[0] 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: except Exception as e:
raise DecryptionError( raise DecryptionError(
@@ -337,6 +450,47 @@ def decrypt_message(
) from e ) 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]: def get_date_from_encrypted(encrypted_data: bytes) -> Optional[str]:
""" """
Extract the date string from encrypted data without decrypting. Extract the date string from encrypted data without decrypting.

View File

@@ -6,7 +6,7 @@ Dataclasses for structured data exchange between modules and frontends.
from dataclasses import dataclass, field from dataclasses import dataclass, field
from datetime import date from datetime import date
from typing import Optional from typing import Optional, Union
@dataclass @dataclass
@@ -43,10 +43,35 @@ class Credentials:
return self.phrase_entropy + self.pin_entropy + self.rsa_entropy 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 @dataclass
class EncodeInput: class EncodeInput:
"""Input parameters for encoding a message.""" """Input parameters for encoding a message."""
message: str message: Union[str, bytes, FilePayload] # Text, raw bytes, or file
reference_photo: bytes reference_photo: bytes
carrier_image: bytes carrier_image: bytes
day_phrase: str day_phrase: str
@@ -90,8 +115,26 @@ class DecodeInput:
@dataclass @dataclass
class DecodeResult: class DecodeResult:
"""Result of decoding operation.""" """Result of decoding operation."""
message: str payload_type: str # 'text' or 'file'
date_encoded: str 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 @dataclass

View File

@@ -16,6 +16,43 @@ from .models import EmbedStats
from .exceptions import CapacityError, ExtractionError, EmbeddingError 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]: 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.
@@ -83,8 +120,9 @@ 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,
) -> tuple[bytes, EmbedStats]: output_format: Optional[str] = None
) -> tuple[bytes, EmbedStats, str]:
""" """
Embed encrypted data in carrier image using LSB steganography. Embed encrypted data in carrier image using LSB steganography.
@@ -96,9 +134,11 @@ def embed_in_image(
encrypted_data: Data to embed encrypted_data: Data to embed
pixel_key: Key for pixel selection pixel_key: Key for pixel selection
bits_per_channel: Bits to use per color channel (1-2) 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: Returns:
Tuple of (PNG image bytes, EmbedStats) Tuple of (image bytes, EmbedStats, file extension)
Raises: Raises:
CapacityError: If carrier is too small CapacityError: If carrier is too small
@@ -106,6 +146,8 @@ def embed_in_image(
""" """
try: try:
img = Image.open(io.BytesIO(carrier_data)) img = Image.open(io.BytesIO(carrier_data))
input_format = img.format
if img.mode != 'RGB': if img.mode != 'RGB':
img = img.convert('RGB') img = img.convert('RGB')
@@ -160,8 +202,15 @@ def embed_in_image(
stego_img = Image.new('RGB', img.size) stego_img = Image.new('RGB', img.size)
stego_img.putdata(new_pixels) 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() output = io.BytesIO()
stego_img.save(output, 'PNG') stego_img.save(output, out_fmt)
output.seek(0) output.seek(0)
stats = EmbedStats( stats = EmbedStats(
@@ -171,7 +220,7 @@ def embed_in_image(
bytes_embedded=len(data_with_len) bytes_embedded=len(data_with_len)
) )
return output.getvalue(), stats return output.getvalue(), stats, out_ext
except CapacityError: except CapacityError:
raise raise
@@ -284,3 +333,18 @@ def get_image_dimensions(image_data: bytes) -> tuple[int, int]:
"""Get image dimensions without loading full image.""" """Get image dimensions without loading full image."""
img = Image.open(io.BytesIO(image_data)) img = Image.open(io.BytesIO(image_data))
return img.size 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

@@ -15,15 +15,20 @@ from typing import Optional
from .constants import DAY_NAMES 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. Generate a filename for stego images.
Format: {prefix}{random}_{YYYYMMDD}.png Format: {prefix}{random}_{YYYYMMDD}.{extension}
Args: Args:
date_str: Date string (YYYY-MM-DD), defaults to today date_str: Date string (YYYY-MM-DD), defaults to today
prefix: Optional prefix prefix: Optional prefix
extension: File extension without dot (default: 'png')
Returns: Returns:
Filename string Filename string
@@ -34,7 +39,10 @@ def generate_filename(date_str: Optional[str] = None, prefix: str = "") -> str:
date_compact = date_str.replace('-', '') date_compact = date_str.replace('-', '')
random_hex = secrets.token_hex(4) 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]: def parse_date_from_filename(filename: str) -> Optional[str]:

View File

@@ -5,17 +5,17 @@ Validators for all user inputs with clear error messages.
""" """
import io import io
from typing import Optional from typing import Optional, Union
from PIL import Image from PIL import Image
from .constants import ( from .constants import (
MIN_PIN_LENGTH, MAX_PIN_LENGTH, 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, MIN_RSA_BITS, MIN_KEY_PASSWORD_LENGTH,
ALLOWED_IMAGE_EXTENSIONS, ALLOWED_KEY_EXTENSIONS, ALLOWED_IMAGE_EXTENSIONS, ALLOWED_KEY_EXTENSIONS,
) )
from .models import ValidationResult from .models import ValidationResult, FilePayload
from .exceptions import ( from .exceptions import (
ValidationError, PinValidationError, MessageValidationError, ValidationError, PinValidationError, MessageValidationError,
ImageValidationError, KeyValidationError, SecurityFactorError, ImageValidationError, KeyValidationError, SecurityFactorError,
@@ -61,7 +61,7 @@ def validate_pin(pin: str, required: bool = False) -> ValidationResult:
def validate_message(message: str) -> ValidationResult: def validate_message(message: str) -> ValidationResult:
""" """
Validate message content and size. Validate text message content and size.
Args: Args:
message: Message text message: Message text
@@ -80,6 +80,81 @@ def validate_message(message: str) -> ValidationResult:
return ValidationResult.ok(length=len(message)) 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( def validate_image(
image_data: bytes, image_data: bytes,
name: str = "Image", name: str = "Image",
@@ -319,6 +394,13 @@ def require_valid_message(message: str) -> None:
raise MessageValidationError(result.error_message) 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: def require_valid_image(image_data: bytes, name: str = "Image") -> None:
"""Validate image, raising exception on failure.""" """Validate image, raising exception on failure."""
result = validate_image(image_data, name) result = validate_image(image_data, name)

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 MiB

BIN
test_data/scandal.txt.gz Normal file

Binary file not shown.

View File

@@ -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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

View File

@@ -22,6 +22,7 @@ from stegasoo import (
decode, decode,
DAY_NAMES, DAY_NAMES,
) )
from stegasoo.steganography import get_output_format, get_image_format
class TestKeygen: class TestKeygen:
@@ -127,19 +128,71 @@ class TestValidation:
assert not result.is_valid 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: class TestEncodeDecode:
"""Test encoding and decoding (requires test images).""" """Test encoding and decoding (requires test images)."""
@pytest.fixture @pytest.fixture
def test_image(self): def png_image(self):
"""Create a simple test image.""" """Create a simple PNG test image."""
from PIL import Image from PIL import Image
img = Image.new('RGB', (100, 100), color='red') img = Image.new('RGB', (100, 100), color='red')
buf = io.BytesIO() buf = io.BytesIO()
img.save(buf, format='PNG') img.save(buf, format='PNG')
return buf.getvalue() 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.""" """Test full encode/decode cycle."""
message = "Secret message!" message = "Secret message!"
phrase = "apple forest thunder" phrase = "apple forest thunder"
@@ -147,8 +200,8 @@ class TestEncodeDecode:
result = encode( result = encode(
message=message, message=message,
reference_photo=test_image, reference_photo=png_image,
carrier_image=test_image, carrier_image=png_image,
day_phrase=phrase, day_phrase=phrase,
pin=pin pin=pin
) )
@@ -159,19 +212,89 @@ class TestEncodeDecode:
decoded = decode( decoded = decode(
stego_image=result.stego_image, stego_image=result.stego_image,
reference_photo=test_image, reference_photo=png_image,
day_phrase=phrase, day_phrase=phrase,
pin=pin pin=pin
) )
assert decoded == message 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.""" """Test that wrong PIN fails to decode."""
result = encode( result = encode(
message="Secret", message="Secret",
reference_photo=test_image, reference_photo=png_image,
carrier_image=test_image, carrier_image=png_image,
day_phrase="test phrase here", day_phrase="test phrase here",
pin="123456" pin="123456"
) )
@@ -179,17 +302,17 @@ class TestEncodeDecode:
with pytest.raises(stegasoo.DecryptionError): with pytest.raises(stegasoo.DecryptionError):
decode( decode(
stego_image=result.stego_image, stego_image=result.stego_image,
reference_photo=test_image, reference_photo=png_image,
day_phrase="test phrase here", day_phrase="test phrase here",
pin="654321" # Wrong PIN 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.""" """Test that wrong phrase fails to decode."""
result = encode( result = encode(
message="Secret", message="Secret",
reference_photo=test_image, reference_photo=png_image,
carrier_image=test_image, carrier_image=png_image,
day_phrase="correct phrase here", day_phrase="correct phrase here",
pin="123456" pin="123456"
) )
@@ -197,7 +320,7 @@ class TestEncodeDecode:
with pytest.raises(stegasoo.DecryptionError): with pytest.raises(stegasoo.DecryptionError):
decode( decode(
stego_image=result.stego_image, stego_image=result.stego_image,
reference_photo=test_image, reference_photo=png_image,
day_phrase="wrong phrase here", day_phrase="wrong phrase here",
pin="123456" pin="123456"
) )