Added file support and increased file limits.
This commit is contained in:
@@ -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.")
|
||||||
|
|||||||
@@ -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 output:
|
if result.is_file:
|
||||||
Path(output).write_text(message)
|
# File content
|
||||||
if not quiet:
|
if output:
|
||||||
click.secho(f"✓ Decoded successfully!", fg='green')
|
out_path = Path(output)
|
||||||
click.echo(f" Saved to: {output}")
|
elif result.filename:
|
||||||
else:
|
out_path = Path(result.filename)
|
||||||
if quiet:
|
|
||||||
click.echo(message)
|
|
||||||
else:
|
else:
|
||||||
click.secho("✓ Decoded successfully!", fg='green')
|
out_path = Path("decoded_file")
|
||||||
click.echo()
|
|
||||||
click.echo(message)
|
if out_path.exists() and not force:
|
||||||
|
raise click.ClickException(
|
||||||
|
f"Output file '{out_path}' exists. Use --force to overwrite."
|
||||||
|
)
|
||||||
|
|
||||||
|
out_path.write_bytes(result.file_data)
|
||||||
|
|
||||||
|
if not quiet:
|
||||||
|
click.secho("✓ Decoded file successfully!", fg='green')
|
||||||
|
click.echo(f" Saved to: {out_path}")
|
||||||
|
click.echo(f" Size: {len(result.file_data):,} bytes")
|
||||||
|
if result.mime_type:
|
||||||
|
click.echo(f" Type: {result.mime_type}")
|
||||||
|
else:
|
||||||
|
# Text content
|
||||||
|
if output:
|
||||||
|
Path(output).write_text(result.message)
|
||||||
|
if not quiet:
|
||||||
|
click.secho("✓ Decoded successfully!", fg='green')
|
||||||
|
click.echo(f" Saved to: {output}")
|
||||||
|
else:
|
||||||
|
if quiet:
|
||||||
|
click.echo(result.message)
|
||||||
|
else:
|
||||||
|
click.secho("✓ Decoded successfully!", fg='green')
|
||||||
|
click.echo()
|
||||||
|
click.echo(result.message)
|
||||||
|
|
||||||
except (DecryptionError, ExtractionError) as e:
|
except (DecryptionError, ExtractionError) as e:
|
||||||
raise click.ClickException(f"Decryption failed: {e}")
|
raise click.ClickException(f"Decryption failed: {e}")
|
||||||
|
|||||||
@@ -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
|
||||||
result = validate_message(message)
|
if payload_type == 'file' and payload_file and payload_file.filename:
|
||||||
if not result.is_valid:
|
# File payload
|
||||||
flash(result.error_message, 'error')
|
file_data = payload_file.read()
|
||||||
return render_template('encode.html', day_of_week=day_of_week)
|
|
||||||
|
result = validate_file_payload(file_data, payload_file.filename)
|
||||||
|
if not result.is_valid:
|
||||||
|
flash(result.error_message, 'error')
|
||||||
|
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb)
|
||||||
|
|
||||||
|
mime_type, _ = mimetypes.guess_type(payload_file.filename)
|
||||||
|
payload = FilePayload(
|
||||||
|
data=file_data,
|
||||||
|
filename=payload_file.filename,
|
||||||
|
mime_type=mime_type
|
||||||
|
)
|
||||||
|
else:
|
||||||
|
# Text message
|
||||||
|
result = validate_message(message)
|
||||||
|
if not result.is_valid:
|
||||||
|
flash(result.error_message, 'error')
|
||||||
|
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb)
|
||||||
|
payload = message
|
||||||
|
|
||||||
if not day_phrase:
|
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
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
@@ -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"]');
|
||||||
|
|
||||||
|
|||||||
@@ -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;
|
||||||
showPreview(e.dataTransfer.files[0]);
|
input.dispatchEvent(new Event('change'));
|
||||||
|
|
||||||
|
if (!isPayloadZone) {
|
||||||
|
showPreview(e.dataTransfer.files[0]);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
input.addEventListener('change', function() {
|
if (!isPayloadZone) {
|
||||||
if (this.files && this.files[0]) {
|
input.addEventListener('change', function() {
|
||||||
showPreview(this.files[0]);
|
if (this.files && 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 => {
|
||||||
preview.src = e.target.result;
|
if (preview) {
|
||||||
preview.classList.remove('d-none');
|
preview.src = e.target.result;
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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',
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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]:
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
BIN
test_data/holmes_shit_20251228.png
Normal file
BIN
test_data/holmes_shit_20251228.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
BIN
test_data/scandal.txt.gz
Normal file
BIN
test_data/scandal.txt.gz
Normal file
Binary file not shown.
353
test_data/scandal.txt.gz.b64
Normal file
353
test_data/scandal.txt.gz.b64
Normal 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
|
||||||
|
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
|
||||||
@@ -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"
|
||||||
)
|
)
|
||||||
|
|||||||
Reference in New Issue
Block a user