Merge pull request #2 from adlee-was-taken/main

Big push to 2.1.3 -- Not worried about the API changes, that's going to keep happening until an actual release is cut.
This commit is contained in:
adlee-was-taken
2025-12-28 21:08:24 -05:00
committed by GitHub
33 changed files with 4642 additions and 1015 deletions

6
.gitignore vendored
View File

@@ -26,6 +26,9 @@ venv/
ENV/
env/
# Old versions of code files.
old_files/
# IDE
.idea/
.vscode/
@@ -51,3 +54,6 @@ htmlcov/
# Distribution
*.manifest
*.spec
# Output test files.
*.png

129
Dockerfile.txt Normal file
View File

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

View File

@@ -3,7 +3,7 @@
Stegasoo REST API
FastAPI-based REST API for steganography operations.
Designed for integration with other services and automation.
Supports both text messages and file embedding.
"""
import io
@@ -28,6 +28,8 @@ from stegasoo import (
DAY_NAMES, __version__,
StegasooError, DecryptionError, CapacityError,
has_argon2,
FilePayload,
MAX_FILE_PAYLOAD_SIZE,
)
from stegasoo.constants import (
MIN_PIN_LENGTH, MAX_PIN_LENGTH,
@@ -35,6 +37,17 @@ from stegasoo.constants import (
VALID_RSA_SIZES,
)
# QR Code utilities
try:
from stegasoo.qr_utils import (
extract_key_from_qr,
has_qr_read,
)
HAS_QR_READ = has_qr_read()
except ImportError:
HAS_QR_READ = False
extract_key_from_qr = None
# ============================================================================
# FASTAPI APP
@@ -42,7 +55,7 @@ from stegasoo.constants import (
app = FastAPI(
title="Stegasoo API",
description="Secure steganography with hybrid authentication",
description="Secure steganography with hybrid authentication. Supports text messages and file embedding.",
version=__version__,
docs_url="/docs",
redoc_url="/redoc",
@@ -79,6 +92,20 @@ class EncodeRequest(BaseModel):
date_str: Optional[str] = None
class EncodeFileRequest(BaseModel):
"""Request for embedding a file (base64-encoded)."""
file_data_base64: str
filename: str
mime_type: Optional[str] = None
reference_photo_base64: str
carrier_image_base64: str
day_phrase: str
pin: str = ""
rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None
date_str: Optional[str] = None
class EncodeResponse(BaseModel):
stego_image_base64: str
filename: str
@@ -97,7 +124,12 @@ class DecodeRequest(BaseModel):
class DecodeResponse(BaseModel):
message: str
"""Response for decode - can be text or file."""
payload_type: str # 'text' or 'file'
message: Optional[str] = None # For text
file_data_base64: Optional[str] = None # For file (base64-encoded)
filename: Optional[str] = None # For file
mime_type: Optional[str] = None # For file
class ImageInfoResponse(BaseModel):
@@ -111,7 +143,15 @@ class ImageInfoResponse(BaseModel):
class StatusResponse(BaseModel):
version: str
has_argon2: bool
has_qrcode_read: bool
day_names: list[str]
max_payload_kb: int
class QrExtractResponse(BaseModel):
success: bool
key_pem: Optional[str] = None
error: Optional[str] = None
class ErrorResponse(BaseModel):
@@ -129,10 +169,43 @@ async def root():
return StatusResponse(
version=__version__,
has_argon2=has_argon2(),
day_names=list(DAY_NAMES)
has_qrcode_read=HAS_QR_READ,
day_names=list(DAY_NAMES),
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024
)
@app.post("/extract-key-from-qr", response_model=QrExtractResponse)
async def api_extract_key_from_qr(
qr_image: UploadFile = File(..., description="QR code image containing RSA key")
):
"""
Extract RSA key from a QR code image.
Supports both compressed (STEGASOO-Z: prefix) and uncompressed keys.
Returns the PEM-encoded key if found.
"""
if not HAS_QR_READ:
raise HTTPException(
501,
"QR code reading not available. Install pyzbar and libzbar."
)
try:
image_data = await qr_image.read()
key_pem = extract_key_from_qr(image_data)
if key_pem:
return QrExtractResponse(success=True, key_pem=key_pem)
else:
return QrExtractResponse(
success=False,
error="No valid RSA key found in QR code"
)
except Exception as e:
return QrExtractResponse(success=False, error=str(e))
@app.post("/generate", response_model=GenerateResponse)
async def api_generate(request: GenerateRequest):
"""
@@ -173,7 +246,7 @@ async def api_generate(request: GenerateRequest):
@app.post("/encode", response_model=EncodeResponse)
async def api_encode(request: EncodeRequest):
"""
Encode a secret message into an image.
Encode a text message into an image.
Images must be base64-encoded. Returns base64-encoded stego image.
"""
@@ -194,8 +267,55 @@ async def api_encode(request: EncodeRequest):
)
stego_b64 = base64.b64encode(result.stego_image).decode('utf-8')
day_of_week = get_day_from_date(result.date_used)
# Get day of week from the date used
return EncodeResponse(
stego_image_base64=stego_b64,
filename=result.filename,
capacity_used_percent=result.capacity_percent,
date_used=result.date_used,
day_of_week=day_of_week
)
except CapacityError as e:
raise HTTPException(400, str(e))
except StegasooError as e:
raise HTTPException(400, str(e))
except Exception as e:
raise HTTPException(500, str(e))
@app.post("/encode/file", response_model=EncodeResponse)
async def api_encode_file(request: EncodeFileRequest):
"""
Encode a file into an image (JSON with base64).
File data must be base64-encoded.
"""
try:
file_data = base64.b64decode(request.file_data_base64)
ref_photo = base64.b64decode(request.reference_photo_base64)
carrier = base64.b64decode(request.carrier_image_base64)
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
payload = FilePayload(
data=file_data,
filename=request.filename,
mime_type=request.mime_type
)
result = encode(
message=payload,
reference_photo=ref_photo,
carrier_image=carrier,
day_phrase=request.day_phrase,
pin=request.pin,
rsa_key_data=rsa_key,
rsa_password=request.rsa_password,
date_str=request.date_str
)
stego_b64 = base64.b64encode(result.stego_image).decode('utf-8')
day_of_week = get_day_from_date(result.date_used)
return EncodeResponse(
@@ -217,16 +337,16 @@ async def api_encode(request: EncodeRequest):
@app.post("/decode", response_model=DecodeResponse)
async def api_decode(request: DecodeRequest):
"""
Decode a secret message from a stego image.
Decode a message or file from a stego image.
Images must be base64-encoded.
Returns payload_type to indicate if result is text or file.
"""
try:
stego = base64.b64decode(request.stego_image_base64)
ref_photo = base64.b64decode(request.reference_photo_base64)
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
message = decode(
result = decode(
stego_image=stego,
reference_photo=ref_photo,
day_phrase=request.day_phrase,
@@ -235,7 +355,18 @@ async def api_decode(request: DecodeRequest):
rsa_password=request.rsa_password
)
return DecodeResponse(message=message)
if result.is_file:
return DecodeResponse(
payload_type='file',
file_data_base64=base64.b64encode(result.file_data).decode('utf-8'),
filename=result.filename,
mime_type=result.mime_type
)
else:
return DecodeResponse(
payload_type='text',
message=result.message
)
except DecryptionError as e:
raise HTTPException(401, "Decryption failed. Check credentials.")
@@ -247,37 +378,74 @@ async def api_decode(request: DecodeRequest):
@app.post("/encode/multipart")
async def api_encode_multipart(
message: str = Form(...),
day_phrase: str = Form(...),
reference_photo: UploadFile = File(...),
carrier: UploadFile = File(...),
message: str = Form(""),
payload_file: Optional[UploadFile] = File(None),
pin: str = Form(""),
rsa_key: Optional[UploadFile] = File(None),
rsa_key_qr: Optional[UploadFile] = File(None),
rsa_password: str = Form(""),
date_str: str = Form("")
):
"""
Encode using multipart form data (file uploads).
Provide either 'message' (text) or 'payload_file' (binary file).
RSA key can be provided as 'rsa_key' (.pem file) or 'rsa_key_qr' (QR code image).
Returns the stego image directly as PNG with metadata headers.
"""
try:
ref_data = await reference_photo.read()
carrier_data = await carrier.read()
rsa_key_data = await rsa_key.read() if rsa_key else None
# Handle RSA key from .pem file or QR code image
rsa_key_data = None
rsa_key_from_qr = False
if rsa_key and rsa_key.filename:
rsa_key_data = await rsa_key.read()
elif rsa_key_qr and rsa_key_qr.filename:
if not HAS_QR_READ:
raise HTTPException(
501,
"QR code reading not available. Install pyzbar and libzbar."
)
qr_image_data = await rsa_key_qr.read()
key_pem = extract_key_from_qr(qr_image_data)
if not key_pem:
raise HTTPException(400, "Could not extract RSA key from QR code image")
rsa_key_data = key_pem.encode('utf-8')
rsa_key_from_qr = True
# QR code keys are never password-protected
effective_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
# Determine payload
if payload_file and payload_file.filename:
file_data = await payload_file.read()
payload = FilePayload(
data=file_data,
filename=payload_file.filename,
mime_type=payload_file.content_type
)
elif message:
payload = message
else:
raise HTTPException(400, "Must provide either 'message' or 'payload_file'")
result = encode(
message=message,
message=payload,
reference_photo=ref_data,
carrier_image=carrier_data,
day_phrase=day_phrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=rsa_password if rsa_password else None,
rsa_password=effective_password,
date_str=date_str if date_str else None
)
# Get day of week from the date used
day_of_week = get_day_from_date(result.date_used)
return Response(
@@ -306,26 +474,62 @@ async def api_decode_multipart(
stego_image: UploadFile = File(...),
pin: str = Form(""),
rsa_key: Optional[UploadFile] = File(None),
rsa_key_qr: Optional[UploadFile] = File(None),
rsa_password: str = Form("")
):
"""
Decode using multipart form data (file uploads).
RSA key can be provided as 'rsa_key' (.pem file) or 'rsa_key_qr' (QR code image).
Returns JSON with payload_type indicating text or file.
"""
try:
ref_data = await reference_photo.read()
stego_data = await stego_image.read()
rsa_key_data = await rsa_key.read() if rsa_key else None
message = decode(
# Handle RSA key from .pem file or QR code image
rsa_key_data = None
rsa_key_from_qr = False
if rsa_key and rsa_key.filename:
rsa_key_data = await rsa_key.read()
elif rsa_key_qr and rsa_key_qr.filename:
if not HAS_QR_READ:
raise HTTPException(
501,
"QR code reading not available. Install pyzbar and libzbar."
)
qr_image_data = await rsa_key_qr.read()
key_pem = extract_key_from_qr(qr_image_data)
if not key_pem:
raise HTTPException(400, "Could not extract RSA key from QR code image")
rsa_key_data = key_pem.encode('utf-8')
rsa_key_from_qr = True
# QR code keys are never password-protected
effective_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
result = decode(
stego_image=stego_data,
reference_photo=ref_data,
day_phrase=day_phrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=rsa_password if rsa_password else None
rsa_password=effective_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:
raise HTTPException(401, "Decryption failed. Check credentials.")

View File

@@ -20,14 +20,30 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src'))
import stegasoo
from stegasoo import (
encode, decode, generate_credentials,
encode, encode_file, decode,
generate_credentials,
export_rsa_key_pem, load_rsa_key,
validate_image, calculate_capacity,
get_day_from_date, parse_date_from_filename,
DAY_NAMES, __version__,
StegasooError, DecryptionError, ExtractionError,
FilePayload,
)
# QR Code utilities
try:
from stegasoo.qr_utils import (
extract_key_from_qr_file,
generate_qr_code,
has_qr_read, has_qr_write,
can_fit_in_qr, needs_compression,
)
HAS_QR = True
except ImportError:
HAS_QR = False
has_qr_read = lambda: False
has_qr_write = lambda: False
# ============================================================================
# CLI SETUP
@@ -42,7 +58,7 @@ def cli():
"""
Stegasoo - Secure steganography with hybrid authentication.
Hide encrypted messages in images using a combination of:
Hide encrypted messages or files in images using a combination of:
\b
• Reference photo (something you have)
@@ -170,59 +186,101 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
@cli.command()
@click.option('--ref', '-r', required=True, type=click.Path(exists=True), help='Reference photo')
@click.option('--carrier', '-c', required=True, type=click.Path(exists=True), help='Carrier image')
@click.option('--message', '-m', help='Message to encode (or use stdin)')
@click.option('--message-file', '-f', type=click.Path(exists=True), help='Read message from file')
@click.option('--message', '-m', help='Text message to encode')
@click.option('--message-file', '-f', type=click.Path(exists=True), help='Read text message from file')
@click.option('--embed-file', '-e', type=click.Path(exists=True), help='Embed a file (binary)')
@click.option('--phrase', '-p', required=True, help='Day phrase')
@click.option('--pin', help='Static PIN')
@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file')
@click.option('--key-password', help='RSA key password')
@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file (.pem)')
@click.option('--key-qr', type=click.Path(exists=True), help='RSA key from QR code image')
@click.option('--key-password', help='RSA key password (for encrypted .pem files)')
@click.option('--output', '-o', type=click.Path(), help='Output file (default: auto-generated)')
@click.option('--date', 'date_str', help='Date override (YYYY-MM-DD)')
@click.option('--quiet', '-q', is_flag=True, help='Suppress output except errors')
def encode_cmd(ref, carrier, message, message_file, phrase, pin, key, key_password, output, date_str, quiet):
def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key, key_qr, key_password, output, date_str, quiet):
"""
Encode a secret message into an image.
Encode a secret message or file into an image.
Requires a reference photo, carrier image, and day phrase.
Must provide either --pin or --key (or both).
Must provide either --pin or --key/--key-qr (or both).
For text messages, use -m or -f or pipe via stdin.
For binary files, use -e/--embed-file.
RSA key can be provided as a .pem file (--key) or QR code image (--key-qr).
\b
Examples:
# Text message with PIN
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"
# With RSA key file
stegasoo encode -r photo.jpg -c meme.png -p "words" -k mykey.pem -m "secret"
# With RSA key from QR code image
stegasoo encode -r photo.jpg -c meme.png -p "words" --key-qr keyqr.png -m "secret"
# Embed a binary file
stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -e secret.pdf
"""
# Get message
if message:
msg = message
elif message_file:
msg = Path(message_file).read_text()
elif not sys.stdin.isatty():
msg = sys.stdin.read()
else:
raise click.UsageError("Must provide message via -m, -f, or stdin")
# Determine what to encode
payload = None
# Load key if provided
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:
payload = Path(message_file).read_text()
elif not sys.stdin.isatty():
payload = sys.stdin.read()
else:
raise click.UsageError("Must provide message via -m, -f, -e, or stdin")
# Load key if provided (from .pem file or QR code image)
rsa_key_data = None
rsa_key_from_qr = False
if key and key_qr:
raise click.UsageError("Cannot use both --key and --key-qr. Choose one.")
if key:
rsa_key_data = Path(key).read_bytes()
elif key_qr:
if not HAS_QR or not has_qr_read():
raise click.ClickException(
"QR code reading not available. Install: pip install pyzbar\n"
"Also requires system library: sudo apt-get install libzbar0"
)
key_pem = extract_key_from_qr_file(key_qr)
if not key_pem:
raise click.ClickException(f"Could not extract RSA key from QR code: {key_qr}")
rsa_key_data = key_pem.encode('utf-8')
rsa_key_from_qr = True
if not quiet:
click.echo(f"Loaded RSA key from QR code: {key_qr}")
# QR code keys are never password-protected
effective_key_password = None if rsa_key_from_qr else key_password
# Validate security factors
if not pin and not rsa_key_data:
raise click.UsageError("Must provide --pin or --key (or both)")
raise click.UsageError("Must provide --pin or --key/--key-qr (or both)")
try:
ref_photo = Path(ref).read_bytes()
carrier_image = Path(carrier).read_bytes()
result = encode(
message=msg,
message=payload,
reference_photo=ref_photo,
carrier_image=carrier_image,
day_phrase=phrase,
pin=pin or "",
rsa_key_data=rsa_key_data,
rsa_password=key_password,
rsa_password=effective_key_password,
date_str=date_str,
)
@@ -257,56 +315,113 @@ def encode_cmd(ref, carrier, message, message_file, phrase, pin, key, key_passwo
@click.option('--stego', '-s', required=True, type=click.Path(exists=True), help='Stego image')
@click.option('--phrase', '-p', required=True, help='Day phrase')
@click.option('--pin', help='Static PIN')
@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file')
@click.option('--key-password', help='RSA key password')
@click.option('--output', '-o', type=click.Path(), help='Save message to file')
@click.option('--quiet', '-q', is_flag=True, help='Output only the message')
def decode_cmd(ref, stego, phrase, pin, key, key_password, output, quiet):
@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file (.pem)')
@click.option('--key-qr', type=click.Path(exists=True), help='RSA key from QR code image')
@click.option('--key-password', help='RSA key password (for encrypted .pem files)')
@click.option('--output', '-o', type=click.Path(), help='Save decoded content to file')
@click.option('--quiet', '-q', is_flag=True, help='Output only the content (for text) or suppress messages (for files)')
@click.option('--force', is_flag=True, help='Overwrite existing output file')
def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, quiet, force):
"""
Decode a secret message from a stego image.
Decode a secret message or file from a stego image.
Must use the same credentials that were used for encoding.
Automatically detects whether content is text or a file.
RSA key can be provided as a .pem file (--key) or QR code image (--key-qr).
\b
Examples:
# Decode with PIN
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 with RSA key file
stegasoo decode -r photo.jpg -s stego.png -p "words" -k mykey.pem
# Decode with RSA key from QR code image
stegasoo decode -r photo.jpg -s stego.png -p "words" --key-qr keyqr.png
# Save output to file
stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 -o output.txt
"""
# Load key if provided
# Load key if provided (from .pem file or QR code image)
rsa_key_data = None
rsa_key_from_qr = False
if key and key_qr:
raise click.UsageError("Cannot use both --key and --key-qr. Choose one.")
if key:
rsa_key_data = Path(key).read_bytes()
elif key_qr:
if not HAS_QR or not has_qr_read():
raise click.ClickException(
"QR code reading not available. Install: pip install pyzbar\n"
"Also requires system library: sudo apt-get install libzbar0"
)
key_pem = extract_key_from_qr_file(key_qr)
if not key_pem:
raise click.ClickException(f"Could not extract RSA key from QR code: {key_qr}")
rsa_key_data = key_pem.encode('utf-8')
rsa_key_from_qr = True
if not quiet:
click.echo(f"Loaded RSA key from QR code: {key_qr}")
# QR code keys are never password-protected
effective_key_password = None if rsa_key_from_qr else key_password
# Validate security factors
if not pin and not rsa_key_data:
raise click.UsageError("Must provide --pin or --key (or both)")
raise click.UsageError("Must provide --pin or --key/--key-qr (or both)")
try:
ref_photo = Path(ref).read_bytes()
stego_image = Path(stego).read_bytes()
message = decode(
result = decode(
stego_image=stego_image,
reference_photo=ref_photo,
day_phrase=phrase,
pin=pin or "",
rsa_key_data=rsa_key_data,
rsa_password=key_password,
rsa_password=effective_key_password,
)
if output:
Path(output).write_text(message)
if not quiet:
click.secho(f"✓ Decoded successfully!", fg='green')
click.echo(f" Saved to: {output}")
else:
if quiet:
click.echo(message)
if result.is_file:
# File content
if output:
out_path = Path(output)
elif result.filename:
out_path = Path(result.filename)
else:
click.secho("✓ Decoded successfully!", fg='green')
click.echo()
click.echo(message)
out_path = Path("decoded_file")
if out_path.exists() and not force:
raise click.ClickException(
f"Output file '{out_path}' exists. Use --force to overwrite."
)
out_path.write_bytes(result.file_data)
if not quiet:
click.secho("✓ Decoded file successfully!", fg='green')
click.echo(f" Saved to: {out_path}")
click.echo(f" Size: {len(result.file_data):,} bytes")
if result.mime_type:
click.echo(f" Type: {result.mime_type}")
else:
# Text content
if output:
Path(output).write_text(result.message)
if not quiet:
click.secho("✓ Decoded successfully!", fg='green')
click.echo(f" Saved to: {output}")
else:
if quiet:
click.echo(result.message)
else:
click.secho("✓ Decoded successfully!", fg='green')
click.echo()
click.echo(result.message)
except (DecryptionError, ExtractionError) as e:
raise click.ClickException(f"Decryption failed: {e}")

View File

@@ -3,15 +3,17 @@
Stegasoo Web Frontend
Flask-based web UI for steganography operations.
This is a thin wrapper around the stegasoo library.
Supports both text messages and file embedding.
"""
import io
import sys
import time
import secrets
import mimetypes
from pathlib import Path
from datetime import datetime
from PIL import Image
from flask import (
Flask, render_template, request, send_file,
@@ -27,14 +29,44 @@ from stegasoo import (
export_rsa_key_pem, load_rsa_key,
validate_pin, validate_message, validate_image,
validate_rsa_key, validate_security_factors,
validate_file_payload,
get_today_day, generate_filename,
DAY_NAMES, __version__,
StegasooError, DecryptionError, CapacityError,
has_argon2,
FilePayload,
MAX_FILE_PAYLOAD_SIZE,
)
from stegasoo.constants import (
MAX_MESSAGE_SIZE, MIN_PIN_LENGTH, MAX_PIN_LENGTH,
VALID_RSA_SIZES,
VALID_RSA_SIZES, MAX_FILE_SIZE,
)
# QR Code support
try:
import qrcode
from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_M
HAS_QRCODE = True
except ImportError:
HAS_QRCODE = False
# QR Code reading
try:
from pyzbar.pyzbar import decode as pyzbar_decode
HAS_QRCODE_READ = True
except ImportError:
HAS_QRCODE_READ = False
import zlib
import base64
# Import QR utilities
from stegasoo.qr_utils import (
compress_data, decompress_data, auto_decompress,
is_compressed, can_fit_in_qr, needs_compression,
generate_qr_code, read_qr_code, extract_key_from_qr,
has_qr_write, has_qr_read,
QR_MAX_BINARY, COMPRESSION_PREFIX
)
@@ -44,19 +76,83 @@ from stegasoo.constants import (
app = Flask(__name__)
app.secret_key = secrets.token_hex(32)
app.config['MAX_CONTENT_LENGTH'] = 5 * 1024 * 1024 # 5MB max upload
app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE # 10MB max upload
# Temporary file storage for sharing (file_id -> {data, timestamp, filename})
TEMP_FILES: dict[str, dict] = {}
THUMBNAIL_FILES: dict[str, bytes] = {}
TEMP_FILE_EXPIRY = 300 # 5 minutes
THUMBNAIL_SIZE = (250, 250) # Maximum dimensions for thumbnail
# ============================================================================
# CONFIGURATION
# ============================================================================
# Override stegasoo limits for larger files
# Note: You might need to modify the stegasoo library itself
# to actually increase these limits in its internal calculations
# Flask upload limit (30MB)
MAX_UPLOAD_SIZE = 30 * 1024 * 1024
# Try to import and override stegasoo constants if possible
try:
# Check current limits
print(f"Current MAX_FILE_SIZE from constants: {MAX_FILE_SIZE}")
print(f"Current MAX_FILE_PAYLOAD_SIZE: {MAX_FILE_PAYLOAD_SIZE}")
# Try to increase payload size limit (in bytes)
# 15MB should be enough for 7.6MB files with overhead
DESIRED_PAYLOAD_SIZE = 15 * 1024 * 1024 # 15MB
# Note: You might need to patch the stegasoo module
# if MAX_FILE_PAYLOAD_SIZE is used internally
import stegasoo
if hasattr(stegasoo, 'MAX_FILE_PAYLOAD_SIZE'):
print(f"Overriding MAX_FILE_PAYLOAD_SIZE to {DESIRED_PAYLOAD_SIZE}")
stegasoo.MAX_FILE_PAYLOAD_SIZE = DESIRED_PAYLOAD_SIZE
except Exception as e:
print(f"Could not override stegasoo limits: {e}")
def generate_thumbnail(image_data: bytes, size: tuple = THUMBNAIL_SIZE) -> bytes:
"""Generate thumbnail from image data."""
try:
with Image.open(io.BytesIO(image_data)) as img:
# Convert to RGB if necessary
if img.mode in ('RGBA', 'LA', 'P'):
# Create white background for transparent images
background = Image.new('RGB', img.size, (255, 255, 255))
if img.mode == 'P':
img = img.convert('RGBA')
background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None)
img = background
elif img.mode != 'RGB':
img = img.convert('RGB')
# Create thumbnail
img.thumbnail(size, Image.Resampling.LANCZOS)
# Save to bytes
buffer = io.BytesIO()
img.save(buffer, format='JPEG', quality=85, optimize=True)
return buffer.getvalue()
except Exception as e:
# Log error but don't crash
print(f"Thumbnail generation error: {e}")
return None
def cleanup_temp_files():
"""Remove expired temporary files."""
now = time.time()
expired = [fid for fid, info in TEMP_FILES.items() if now - info['timestamp'] > TEMP_FILE_EXPIRY]
for fid in expired:
TEMP_FILES.pop(fid, None)
# Also clean up corresponding thumbnail
thumb_id = f"{fid}_thumb"
THUMBNAIL_FILES.pop(thumb_id, None)
def allowed_image(filename: str) -> bool:
@@ -67,6 +163,16 @@ def allowed_image(filename: str) -> bool:
return ext in {'png', 'jpg', 'jpeg', 'bmp', 'gif'}
def format_size(size_bytes: int) -> str:
"""Format file size for display."""
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
else:
return f"{size_bytes / (1024 * 1024):.1f} MB"
# ============================================================================
# ROUTES
# ============================================================================
@@ -85,7 +191,7 @@ def generate():
if not use_pin and not use_rsa:
flash('You must select at least one security factor (PIN or RSA Key)', 'error')
return render_template('generate.html', generated=False, has_ml=False)
return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE)
pin_length = int(request.form.get('pin_length', 6))
rsa_bits = int(request.form.get('rsa_bits', 2048))
@@ -105,6 +211,31 @@ def generate():
words_per_phrase=words_per_phrase
)
# Store RSA key temporarily for QR generation
qr_token = None
qr_needs_compression = False
qr_too_large = False
if creds.rsa_key_pem and HAS_QRCODE:
# Check if key fits in QR code
if can_fit_in_qr(creds.rsa_key_pem, compress=False):
qr_needs_compression = False
elif can_fit_in_qr(creds.rsa_key_pem, compress=True):
qr_needs_compression = True
else:
qr_too_large = True
if not qr_too_large:
qr_token = secrets.token_urlsafe(16)
cleanup_temp_files()
TEMP_FILES[qr_token] = {
'data': creds.rsa_key_pem.encode(),
'filename': 'rsa_key.pem',
'timestamp': time.time(),
'type': 'rsa_key',
'compress': qr_needs_compression
}
return render_template('generate.html',
phrases=creds.phrases,
pin=creds.pin,
@@ -120,13 +251,71 @@ def generate():
pin_entropy=creds.pin_entropy,
rsa_entropy=creds.rsa_entropy,
total_entropy=creds.total_entropy,
has_ml=False
has_qrcode=HAS_QRCODE,
qr_token=qr_token,
qr_needs_compression=qr_needs_compression,
qr_too_large=qr_too_large
)
except Exception as e:
flash(f'Error generating credentials: {e}', 'error')
return render_template('generate.html', generated=False, has_ml=False)
return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE)
return render_template('generate.html', generated=False, has_ml=False)
return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE)
@app.route('/generate/qr/<token>')
def generate_qr(token):
"""Generate QR code for RSA key."""
if not HAS_QRCODE:
return "QR code support not available", 501
if token not in TEMP_FILES:
return "Token expired or invalid", 404
file_info = TEMP_FILES[token]
if file_info.get('type') != 'rsa_key':
return "Invalid token type", 400
try:
key_pem = file_info['data'].decode('utf-8')
compress = file_info.get('compress', False)
qr_png = generate_qr_code(key_pem, compress=compress)
return send_file(
io.BytesIO(qr_png),
mimetype='image/png',
as_attachment=False
)
except Exception as e:
return f"Error generating QR code: {e}", 500
@app.route('/generate/qr-download/<token>')
def generate_qr_download(token):
"""Download QR code as PNG file."""
if not HAS_QRCODE:
return "QR code support not available", 501
if token not in TEMP_FILES:
return "Token expired or invalid", 404
file_info = TEMP_FILES[token]
if file_info.get('type') != 'rsa_key':
return "Invalid token type", 400
try:
key_pem = file_info['data'].decode('utf-8')
compress = file_info.get('compress', False)
qr_png = generate_qr_code(key_pem, compress=compress)
return send_file(
io.BytesIO(qr_png),
mimetype='image/png',
as_attachment=True,
download_name='stegasoo_rsa_key_qr.png'
)
except Exception as e:
return f"Error generating QR code: {e}", 500
@app.route('/generate/download-key', methods=['POST'])
@@ -134,22 +323,22 @@ def download_key():
"""Download RSA key as password-protected PEM file."""
key_pem = request.form.get('key_pem', '')
password = request.form.get('key_password', '')
if not key_pem:
flash('No key to download', 'error')
return redirect(url_for('generate'))
if not password or len(password) < 8:
flash('Password must be at least 8 characters', 'error')
return redirect(url_for('generate'))
try:
private_key = load_rsa_key(key_pem.encode())
private_key = load_rsa_key(key_pem.encode('utf-8'))
encrypted_pem = export_rsa_key_pem(private_key, password=password)
key_id = secrets.token_hex(4)
filename = f'stegasoo_key_{private_key.key_size}_{key_id}.pem'
return send_file(
io.BytesIO(encrypted_pem),
mimetype='application/x-pem-file',
@@ -161,9 +350,51 @@ def download_key():
return redirect(url_for('generate'))
@app.route('/extract-key-from-qr', methods=['POST'])
def extract_key_from_qr_route():
"""
Extract RSA key from uploaded QR code image.
Returns JSON with the extracted key or error.
"""
if not HAS_QRCODE_READ:
return jsonify({
'success': False,
'error': 'QR code reading not available. Install pyzbar and libzbar.'
}), 501
qr_image = request.files.get('qr_image')
if not qr_image:
return jsonify({
'success': False,
'error': 'No QR image provided'
}), 400
try:
image_data = qr_image.read()
key_pem = extract_key_from_qr(image_data)
if key_pem:
return jsonify({
'success': True,
'key_pem': key_pem
})
else:
return jsonify({
'success': False,
'error': 'No valid RSA key found in QR code'
}), 400
except Exception as e:
return jsonify({
'success': False,
'error': str(e)
}), 500
@app.route('/encode', methods=['GET', 'POST'])
def encode_page():
day_of_week = get_today_day()
max_payload_kb = MAX_FILE_PAYLOAD_SIZE // 1024
if request.method == 'POST':
try:
@@ -171,61 +402,102 @@ def encode_page():
ref_photo = request.files.get('reference_photo')
carrier = request.files.get('carrier')
rsa_key_file = request.files.get('rsa_key')
payload_file = request.files.get('payload_file')
if not ref_photo or not carrier:
flash('Both reference photo and carrier image are required', 'error')
return render_template('encode.html', day_of_week=day_of_week)
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
if not allowed_image(ref_photo.filename) or not allowed_image(carrier.filename):
flash('Invalid file type. Use PNG, JPG, or BMP', 'error')
return render_template('encode.html', day_of_week=day_of_week)
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Get form data
message = request.form.get('message', '')
day_phrase = request.form.get('day_phrase', '')
pin = request.form.get('pin', '').strip()
rsa_password = request.form.get('rsa_password', '')
payload_type = request.form.get('payload_type', 'text')
# Validate message
result = validate_message(message)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week)
# Determine payload
if payload_type == 'file' and payload_file and payload_file.filename:
# File payload
file_data = payload_file.read()
result = validate_file_payload(file_data, payload_file.filename)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
mime_type, _ = mimetypes.guess_type(payload_file.filename)
payload = FilePayload(
data=file_data,
filename=payload_file.filename,
mime_type=mime_type
)
else:
# Text message
result = validate_message(message)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
payload = message
if not day_phrase:
flash('Day phrase is required', 'error')
return render_template('encode.html', day_of_week=day_of_week)
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Read files
ref_data = ref_photo.read()
carrier_data = carrier.read()
rsa_key_data = rsa_key_file.read() if rsa_key_file and rsa_key_file.filename else None
# Handle RSA key - can come from .pem file or QR code image
rsa_key_data = None
rsa_key_qr = request.files.get('rsa_key_qr')
rsa_key_from_qr = False # Track source for password handling
if rsa_key_file and rsa_key_file.filename:
# RSA key from .pem file
rsa_key_data = rsa_key_file.read()
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
# RSA key from QR code image
qr_image_data = rsa_key_qr.read()
key_pem = extract_key_from_qr(qr_image_data)
if key_pem:
rsa_key_data = key_pem.encode('utf-8')
rsa_key_from_qr = True # QR keys are never password-protected
else:
flash('Could not extract RSA key from QR code image. Make sure the image contains a valid QR code.', 'error')
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Validate security factors
result = validate_security_factors(pin, rsa_key_data)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week)
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Validate PIN if provided
if pin:
result = validate_pin(pin)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week)
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Determine key password - QR code keys are never password-protected
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
# Validate RSA key if provided
if rsa_key_data:
result = validate_rsa_key(rsa_key_data, rsa_password if rsa_password else None)
result = validate_rsa_key(rsa_key_data, key_password)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week)
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Validate carrier image
result = validate_image(carrier_data, "Carrier image")
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('encode.html', day_of_week=day_of_week)
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
# Get date
client_date = request.form.get('client_date', '').strip()
@@ -236,13 +508,13 @@ def encode_page():
# Encode
encode_result = encode(
message=message,
message=payload,
reference_photo=ref_data,
carrier_image=carrier_data,
day_phrase=day_phrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=rsa_password if rsa_password else None,
rsa_password=key_password,
date_str=date_str
)
@@ -259,15 +531,15 @@ def encode_page():
except CapacityError as e:
flash(str(e), 'error')
return render_template('encode.html', day_of_week=day_of_week)
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
except StegasooError as e:
flash(str(e), 'error')
return render_template('encode.html', day_of_week=day_of_week)
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
except Exception as e:
flash(f'Error: {e}', 'error')
return render_template('encode.html', day_of_week=day_of_week)
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
return render_template('encode.html', day_of_week=day_of_week)
return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ)
@app.route('/encode/result/<file_id>')
@@ -277,9 +549,32 @@ def encode_result(file_id):
return redirect(url_for('encode_page'))
file_info = TEMP_FILES[file_id]
# Generate thumbnail
thumbnail_data = generate_thumbnail(file_info['data'])
thumbnail_id = None
if thumbnail_data:
thumbnail_id = f"{file_id}_thumb"
THUMBNAIL_FILES[thumbnail_id] = thumbnail_data
return render_template('encode_result.html',
file_id=file_id,
filename=file_info['filename']
filename=file_info['filename'],
thumbnail_url=url_for('encode_thumbnail', thumb_id=thumbnail_id) if thumbnail_id else None
)
@app.route('/encode/thumbnail/<thumb_id>')
def encode_thumbnail(thumb_id):
"""Serve thumbnail image."""
if thumb_id not in THUMBNAIL_FILES:
return "Thumbnail not found", 404
return send_file(
io.BytesIO(THUMBNAIL_FILES[thumb_id]),
mimetype='image/jpeg',
as_attachment=False
)
@@ -299,7 +594,7 @@ def encode_download(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."""
if file_id not in TEMP_FILES:
return "Not found", 404
@@ -317,6 +612,11 @@ def encode_file(file_id):
def encode_cleanup(file_id):
"""Manually cleanup a file after sharing."""
TEMP_FILES.pop(file_id, None)
# Also cleanup thumbnail if exists
thumb_id = f"{file_id}_thumb"
THUMBNAIL_FILES.pop(thumb_id, None)
return jsonify({'status': 'ok'})
@@ -331,7 +631,7 @@ def decode_page():
if not ref_photo or not stego_image:
flash('Both reference photo and stego image are required', 'error')
return render_template('decode.html')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
# Get form data
day_phrase = request.form.get('day_phrase', '')
@@ -340,61 +640,126 @@ def decode_page():
if not day_phrase:
flash('Day phrase is required', 'error')
return render_template('decode.html')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
# Read files
ref_data = ref_photo.read()
stego_data = stego_image.read()
rsa_key_data = rsa_key_file.read() if rsa_key_file and rsa_key_file.filename else None
# Handle RSA key - can come from .pem file or QR code image
rsa_key_data = None
rsa_key_qr = request.files.get('rsa_key_qr')
rsa_key_from_qr = False # Track source for password handling
if rsa_key_file and rsa_key_file.filename:
# RSA key from .pem file
rsa_key_data = rsa_key_file.read()
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
# RSA key from QR code image
qr_image_data = rsa_key_qr.read()
key_pem = extract_key_from_qr(qr_image_data)
if key_pem:
rsa_key_data = key_pem.encode('utf-8')
rsa_key_from_qr = True # QR keys are never password-protected
else:
flash('Could not extract RSA key from QR code image. Make sure the image contains a valid QR code.', 'error')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
# Validate security factors
result = validate_security_factors(pin, rsa_key_data)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('decode.html')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
# Validate PIN if provided
if pin:
result = validate_pin(pin)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('decode.html')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
# Determine key password - QR code keys are never password-protected
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
# Validate RSA key if provided
if rsa_key_data:
result = validate_rsa_key(rsa_key_data, rsa_password if rsa_password else None)
result = validate_rsa_key(rsa_key_data, key_password)
if not result.is_valid:
flash(result.error_message, 'error')
return render_template('decode.html')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
# Decode
message = decode(
decode_result = decode(
stego_image=stego_data,
reference_photo=ref_data,
day_phrase=day_phrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=rsa_password if rsa_password else None
rsa_password=key_password
)
return render_template('decode.html', decoded_message=message)
if decode_result.is_file:
# File content - store temporarily for download
file_id = secrets.token_urlsafe(16)
cleanup_temp_files()
filename = decode_result.filename or 'decoded_file'
TEMP_FILES[file_id] = {
'data': decode_result.file_data,
'filename': filename,
'mime_type': decode_result.mime_type,
'timestamp': time.time()
}
return render_template('decode.html',
decoded_file=True,
file_id=file_id,
filename=filename,
file_size=format_size(len(decode_result.file_data)),
mime_type=decode_result.mime_type
)
else:
# Text content
return render_template('decode.html', decoded_message=decode_result.message)
except DecryptionError:
flash('Decryption failed. Check your phrase, PIN, RSA key, and reference photo.', 'error')
return render_template('decode.html')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
except StegasooError as e:
flash(str(e), 'error')
return render_template('decode.html')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
except Exception as e:
flash(f'Error: {e}', 'error')
return render_template('decode.html')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
return render_template('decode.html')
return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ)
@app.route('/decode/download/<file_id>')
def decode_download(file_id):
"""Download decoded file."""
if file_id not in TEMP_FILES:
flash('File expired or not found.', 'error')
return redirect(url_for('decode_page'))
file_info = TEMP_FILES[file_id]
mime_type = file_info.get('mime_type', 'application/octet-stream')
return send_file(
io.BytesIO(file_info['data']),
mimetype=mime_type,
as_attachment=True,
download_name=file_info['filename']
)
@app.route('/about')
def about():
return render_template('about.html', has_argon2=has_argon2())
return render_template('about.html',
has_argon2=has_argon2(),
has_qrcode_read=HAS_QRCODE_READ,
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024
)
# ============================================================================

View File

@@ -1,112 +1,86 @@
<svg width="32" height="32" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Sunset gradient in the photo -->
<linearGradient id="sunsetGradientFavicon" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#FFD873"/>
<stop offset="40%" stop-color="#FF7E88"/>
<stop offset="75%" stop-color="#D45BFF"/>
<stop offset="100%" stop-color="#3B2A88"/>
<stop offset="0%" stop-color="#FFD873"></stop>
<stop offset="40%" stop-color="#FF7E88"></stop>
<stop offset="75%" stop-color="#D45BFF"></stop>
<stop offset="100%" stop-color="#3B2A88"></stop>
</linearGradient>
<!-- Caption / cipher gradient -->
<linearGradient id="cipherGradientFavicon" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#D2C8FF" stop-opacity="1"/>
<stop offset="100%" stop-color="#A796FF" stop-opacity="1"/>
<stop offset="0%" stop-color="#D2C8FF" stop-opacity="1"></stop>
<stop offset="100%" stop-color="#A796FF" stop-opacity="1"></stop>
</linearGradient>
<!-- GOLD lock body gradient -->
<linearGradient id="lockBodyGradientFavicon" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#FFF4C7"/>
<stop offset="40%" stop-color="#F7D978"/>
<stop offset="100%" stop-color="#D49A27"/>
<stop offset="0%" stop-color="#FFF4C7"></stop>
<stop offset="40%" stop-color="#F7D978"></stop>
<stop offset="100%" stop-color="#D49A27"></stop>
</linearGradient>
<filter id="lockShadowFavicon" x="-15%" y="-15%" width="130%" height="130%">
<feDropShadow dx="0" dy="1" stdDeviation="0.8"
flood-color="#000000" flood-opacity="0.45"/>
<!-- STRONG, CRISP LOCK SHADOW -->
<filter id="lockShadowFavicon" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="1" stdDeviation="0.4" flood-color="#000000" flood-opacity="0.9"></feDropShadow>
</filter>
</defs>
<!-- Inset and scaled: margin + size tweak -->
<!-- Inset and scaled slightly so nothing clips -->
<g transform="translate(5 5) scale(0.9)">
<g transform="rotate(-6 38 44)">
<!-- Frame -->
<rect x="0" y="0" width="76" height="88"
rx="1" ry="1"
fill="#FCFCFF"
stroke="#E0E0F4"
stroke-width="1"/>
<rect x="0" y="0" width="76" height="88" rx="1" ry="1" fill="#FCFCFF" stroke="#E0E0F4" stroke-width="1"></rect>
<!-- Photo area (left edge at x=6) -->
<rect x="6" y="6" width="64" height="48"
rx="0.5" ry="0.5"
fill="url(#sunsetGradientFavicon)"
stroke="#3C3C46"
stroke-width="1"
stroke-opacity="0.7"/>
<!-- Photo area -->
<rect x="6" y="6" width="64" height="48" rx="0.5" ry="0.5" fill="url(#sunsetGradientFavicon)" stroke="#3C3C46" stroke-width="1" stroke-opacity="0.7"></rect>
<!-- FAR BACKGROUND BUILDINGS -->
<!-- Far background buildings -->
<g fill="#59407E" opacity="0.55">
<rect x="20" y="35" width="4" height="7"/>
<rect x="32" y="32" width="5" height="10"/>
<rect x="34" y="30.5" width="0.7" height="2.3"/>
<rect x="46" y="34" width="4" height="8"/>
<rect x="51" y="36" width="3" height="6"/>
<rect x="20" y="35" width="4" height="7"></rect>
<rect x="32" y="32" width="5" height="10"></rect>
<rect x="34" y="30.5" width="0.7" height="2.3"></rect>
<rect x="46" y="34" width="4" height="8"></rect>
<rect x="51" y="36" width="3" height="6"></rect>
</g>
<!-- Hills / skyline -->
<path d="M6 44 Q22 36 36 39 T70 42 L70 54 L6 54 Z"
fill="#351C6A" opacity="1"/>
<path d="M6 44 Q22 36 36 39 T70 42 L70 54 L6 54 Z" fill="#351C6A" opacity="1"></path>
<!-- Sun -->
<circle cx="54" cy="18" r="7" fill="#FFEBA9" opacity="0.96"/>
<circle cx="54" cy="18" r="7" fill="#FFEBA9" opacity="0.96"></circle>
<!-- Bottom caption area -->
<rect x="1" y="54" width="74" height="32"
fill="#F5F3FF" opacity="0.9"/>
<rect x="1" y="54" width="74" height="32" fill="#F5F3FF" opacity="0.9"></rect>
<!-- DARKER "WRITING": two lines aligned to photo left (x = 6) -->
<!-- "Writing": 2 lines aligned to photo left (x = 6) -->
<g transform="translate(6 62)">
<rect x="0" y="0" width="30" height="3.4" rx="1.7"
fill="url(#cipherGradientFavicon)"/>
<rect x="0" y="6" width="20" height="3.2" rx="1.6"
fill="url(#cipherGradientFavicon)" opacity="0.85"/>
<rect x="0" y="0" width="30" height="3.4" rx="1.7" fill="url(#cipherGradientFavicon)"></rect>
<rect x="0" y="6" width="20" height="3.2" rx="1.6" fill="url(#cipherGradientFavicon)" opacity="0.85"></rect>
</g>
<!-- Large gold lock hanging over corner -->
<!-- Gold lock overlapping corner -->
<g transform="translate(41.32 51.15) scale(1.86)" filter="url(#lockShadowFavicon)">
<rect x="0" y="6" width="24" height="18"
rx="3" ry="3"
fill="url(#lockBodyGradientFavicon)"
stroke="#8A6115"
stroke-width="0.9"/>
<rect x="0" y="6" width="24" height="18" rx="3" ry="3" fill="url(#lockBodyGradientFavicon)" stroke="#8A6115" stroke-width="0.9"></rect>
<rect x="2.2" y="8.2" width="19.6" height="8"
rx="2" ry="2"
fill="#FFFFFF"
opacity="0.16"/>
<rect x="2.2" y="8.2" width="19.6" height="8" rx="2" ry="2" fill="#FFFFFF" opacity="0.16"></rect>
<circle cx="3.4" cy="9.4" r="0.7" fill="#C28A24" opacity="0.9"/>
<circle cx="20.6" cy="9.4" r="0.7" fill="#C28A24" opacity="0.9"/>
<circle cx="3.4" cy="9.4" r="0.7" fill="#C28A24" opacity="0.9"></circle>
<circle cx="20.6" cy="9.4" r="0.7" fill="#C28A24" opacity="0.9"></circle>
<rect x="7.2" y="8.4" width="3.6" height="13"
fill="#FFFFFF" opacity="0.10"/>
<rect x="7.2" y="8.4" width="3.6" height="13" fill="#FFFFFF" opacity="0.10"></rect>
<rect x="2.2" y="17.2" width="19.6" height="1.5"
rx="0.75" fill="#A46D16" opacity="0.45"/>
<rect x="2.2" y="17.2" width="19.6" height="1.5" rx="0.75" fill="#A46D16" opacity="0.45"></rect>
<path d="M6 7.5
v-3.7
a5 5 0 0 1 12 0
v3.7"
fill="none"
stroke="#C28A24"
stroke-width="2.4"
stroke-linecap="round"/>
v3.7" fill="none" stroke="#C28A24" stroke-width="2.4" stroke-linecap="round"></path>
<circle cx="12" cy="15" r="2.3" fill="#4A3210"/>
<rect x="11.2" y="15.7" width="1.6" height="3.4"
rx="0.8" fill="#2F1F08"/>
<circle cx="12" cy="15" r="2.3" fill="#4A3210"></circle>
<rect x="11.2" y="15.7" width="1.6" height="3.4" rx="0.8" fill="#2F1F08"></rect>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 3.8 KiB

View File

@@ -1,165 +1,131 @@
<svg width="512" height="512" viewBox="0 0 96 96" xmlns="http://www.w3.org/2000/svg">
<defs>
<!-- Sunset gradient in the photo -->
<linearGradient id="sunsetGradientPolaroidTight" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#FFD873"/>
<stop offset="40%" stop-color="#FF7E88"/>
<stop offset="75%" stop-color="#D45BFF"/>
<stop offset="100%" stop-color="#3B2A88"/>
<stop offset="0%" stop-color="#FFD873"></stop>
<stop offset="40%" stop-color="#FF7E88"></stop>
<stop offset="75%" stop-color="#D45BFF"></stop>
<stop offset="100%" stop-color="#3B2A88"></stop>
</linearGradient>
<!-- Caption / cipher gradient -->
<linearGradient id="cipherGradientPolaroidTight" x1="0" y1="0" x2="1" y2="0">
<stop offset="0%" stop-color="#D2C8FF" stop-opacity="1"/>
<stop offset="100%" stop-color="#A796FF" stop-opacity="1"/>
<stop offset="0%" stop-color="#D2C8FF" stop-opacity="1"></stop>
<stop offset="100%" stop-color="#A796FF" stop-opacity="1"></stop>
</linearGradient>
<!-- Card shadow -->
<filter id="cardShadowPolaroidTight" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="3" stdDeviation="3.5"
flood-color="#000000" flood-opacity="0.35"/>
<feDropShadow dx="0" dy="3" stdDeviation="3.5" flood-color="#000000" flood-opacity="0.35"></feDropShadow>
</filter>
<!-- GOLD lock body gradient -->
<linearGradient id="lockBodyGradientPolaroidTight" x1="0" y1="0" x2="0" y2="1">
<stop offset="0%" stop-color="#FFF4C7"/>
<stop offset="40%" stop-color="#F7D978"/>
<stop offset="100%" stop-color="#D49A27"/>
<stop offset="0%" stop-color="#FFF4C7"></stop>
<stop offset="40%" stop-color="#F7D978"></stop>
<stop offset="100%" stop-color="#D49A27"></stop>
</linearGradient>
<filter id="lockShadowPolaroidTight" x="-15%" y="-15%" width="130%" height="130%">
<feDropShadow dx="0" dy="1" stdDeviation="0.9"
flood-color="#000000" flood-opacity="0.4"/>
<!-- STRONG, CRISP LOCK SHADOW -->
<filter id="lockShadowPolaroidTight" x="-20%" y="-20%" width="140%" height="140%">
<feDropShadow dx="0" dy="1.2" stdDeviation="0.45" flood-color="#000000" flood-opacity="0.9"></feDropShadow>
</filter>
<!-- Soft blur for contrails -->
<filter id="contrailBlur" x="-10%" y="-10%" width="120%" height="120%">
<feGaussianBlur stdDeviation="0.4" />
<feGaussianBlur stdDeviation="0.4"></feGaussianBlur>
</filter>
</defs>
<!-- Inset and scaled: margin + size tweak -->
<!-- Inset and scaled slightly so nothing clips -->
<g transform="translate(5 5) scale(0.9)" filter="url(#cardShadowPolaroidTight)">
<g transform="rotate(-6 38 44)">
<!-- Polaroid frame -->
<rect x="0" y="0" width="76" height="88"
rx="1" ry="1"
fill="#FCFCFF"
stroke="#E0E0F4"
stroke-width="1"/>
<rect x="0" y="0" width="76" height="88" rx="1" ry="1" fill="#FCFCFF" stroke="#E0E0F4" stroke-width="1"></rect>
<!-- Photo area (left edge at x=6) -->
<rect x="6" y="6" width="64" height="48"
rx="0.5" ry="0.5"
fill="url(#sunsetGradientPolaroidTight)"
stroke="#3C3C46"
stroke-width="1"
stroke-opacity="0.7"/>
<rect x="6" y="6" width="64" height="48" rx="0.5" ry="0.5" fill="url(#sunsetGradientPolaroidTight)" stroke="#3C3C46" stroke-width="1" stroke-opacity="0.7"></rect>
<!-- FAR BACKGROUND BUILDINGS -->
<!-- Far background buildings -->
<g fill="#59407E" opacity="0.55">
<rect x="18" y="35" width="4" height="7"/>
<rect x="30" y="32" width="5" height="10"/>
<rect x="32.2" y="30.2" width="0.6" height="2.2"/>
<rect x="44" y="34" width="4" height="8"/>
<rect x="49" y="36" width="3" height="6"/>
<rect x="18" y="35" width="4" height="7"></rect>
<rect x="30" y="32" width="5" height="10"></rect>
<rect x="32.2" y="30.2" width="0.6" height="2.2"></rect>
<rect x="44" y="34" width="4" height="8"></rect>
<rect x="49" y="36" width="3" height="6"></rect>
</g>
<!-- Hills / skyline -->
<path d="M6 44 Q22 36 36 39 T70 42 L70 54 L6 54 Z"
fill="#351C6A" opacity="1"/>
<path d="M6 44 Q22 36 36 39 T70 42 L70 54 L6 54 Z" fill="#351C6A" opacity="1"></path>
<!-- Sun -->
<circle cx="54" cy="18" r="6.5" fill="#FFEBA9" opacity="0.96"/>
<circle cx="54" cy="18" r="6.5" fill="#FFEBA9" opacity="0.96"></circle>
<!-- Contrails -->
<g filter="url(#contrailBlur)">
<path d="M9 11 L41 7"
stroke="#FFFDFE"
stroke-width="0.7"
stroke-linecap="round"
opacity="0.45"/>
<path d="M18 8 L20 20"
transform="rotate(-26 19 14)"
stroke="#FFFDFE"
stroke-width="0.5"
stroke-linecap="round"
opacity="0.3"/>
<path d="M9 11 L41 7" stroke="#FFFDFE" stroke-width="0.7" stroke-linecap="round" opacity="0.45"></path>
<path d="M18 8 L20 20" transform="rotate(-26 19 14)" stroke="#FFFDFE" stroke-width="0.5" stroke-linecap="round" opacity="0.3"></path>
</g>
<!-- Main flock of birds -->
<g stroke="#2F2348"
stroke-width="0.7"
fill="none"
stroke-linecap="round"
opacity="0.9">
<path d="M26 20 q 2 -1.3 4 0" />
<path d="M31 18 q 1.8 -1.1 3.6 0" />
<path d="M36 16 q 1.5 -0.9 3 0" />
<g stroke="#2F2348" stroke-width="0.7" fill="none" stroke-linecap="round" opacity="0.9">
<path d="M26 20 q 2 -1.3 4 0"></path>
<path d="M31 18 q 1.8 -1.1 3.6 0"></path>
<path d="M36 16 q 1.5 -0.9 3 0"></path>
</g>
<!-- Distant birds -->
<g stroke="#2F2348"
stroke-width="0.5"
fill="none"
stroke-linecap="round"
opacity="0.7">
<path d="M50.2 32.4 q 1.4 -0.8 2.8 0" />
<path d="M53.6 31.5 q 1.2 -0.7 2.4 0" />
<g stroke="#2F2348" stroke-width="0.5" fill="none" stroke-linecap="round" opacity="0.7">
<path d="M50.2 32.4 q 1.4 -0.8 2.8 0"></path>
<path d="M53.6 31.5 q 1.2 -0.7 2.4 0"></path>
</g>
<!-- Bottom caption area -->
<rect x="1" y="54" width="74" height="32"
fill="#F5F3FF" opacity="0.9"/>
<rect x="1" y="54" width="74" height="32" fill="#F5F3FF" opacity="0.9"></rect>
<!-- DARKER "WRITING": three lines aligned to photo left (x = 6) -->
<!-- "Writing": 3 lines aligned to photo left (x = 6) -->
<g transform="translate(6 62)">
<rect x="0" y="0" width="36" height="3.4" rx="1.7"
fill="url(#cipherGradientPolaroidTight)"/>
<rect x="0" y="5.5" width="28" height="3.2" rx="1.6"
fill="url(#cipherGradientPolaroidTight)" opacity="0.9"/>
<rect x="0" y="11" width="20" height="3.0" rx="1.5"
fill="url(#cipherGradientPolaroidTight)" opacity="0.8"/>
<!-- Top line: darker, shorter -->
<rect x="0" y="0" width="28" height="3.4" rx="1.7" fill="#B2A0FF"></rect>
<!-- Second line: a bit longer -->
<rect x="0" y="5.5" width="32" height="3.2" rx="1.6" fill="url(#cipherGradientPolaroidTight)" opacity="0.9"></rect>
<!-- Third line: shortest -->
<rect x="0" y="11" width="22" height="3.0" rx="1.5" fill="url(#cipherGradientPolaroidTight)" opacity="0.8"></rect>
</g>
<!-- Big gold lock hanging over corner -->
<!-- Big gold lock overlapping corner -->
<g transform="translate(41.32 47.37) scale(1.86)" filter="url(#lockShadowPolaroidTight)">
<rect x="0" y="8" width="24" height="18"
rx="3" ry="3"
fill="url(#lockBodyGradientPolaroidTight)"
stroke="#8A6115"
stroke-width="0.8"/>
<!-- Outer body -->
<rect x="0" y="8" width="24" height="18" rx="3" ry="3" fill="url(#lockBodyGradientPolaroidTight)" stroke="#8A6115" stroke-width="0.8"></rect>
<rect x="2.2" y="10" width="19.6" height="9"
rx="2" ry="2"
fill="#FFFFFF"
opacity="0.14"/>
<!-- Inner inset -->
<rect x="2.2" y="10" width="19.6" height="9" rx="2" ry="2" fill="#FFFFFF" opacity="0.14"></rect>
<circle cx="3.4" cy="11" r="0.7" fill="#C28A24" opacity="0.9"/>
<circle cx="20.6" cy="11" r="0.7" fill="#C28A24" opacity="0.9"/>
<!-- Bolts -->
<circle cx="3.4" cy="11" r="0.7" fill="#C28A24" opacity="0.9"></circle>
<circle cx="20.6" cy="11" r="0.7" fill="#C28A24" opacity="0.9"></circle>
<circle cx="3.4" cy="19.5" r="0.6" fill="#A66D1D" opacity="0.85"></circle>
<circle cx="20.6" cy="19.5" r="0.6" fill="#A66D1D" opacity="0.85"></circle>
<circle cx="3.4" cy="19.5" r="0.6" fill="#A66D1D" opacity="0.85"/>
<circle cx="20.6" cy="19.5" r="0.6" fill="#A66D1D" opacity="0.85"/>
<!-- Vertical highlight -->
<rect x="7.2" y="10.2" width="3.6" height="14" fill="#FFFFFF" opacity="0.10"></rect>
<rect x="7.2" y="10.2" width="3.6" height="14"
fill="#FFFFFF" opacity="0.10"/>
<rect x="2.2" y="18.8" width="19.6" height="1.6"
rx="0.8" fill="#A46D16" opacity="0.45"/>
<!-- Bottom inner shadow strip -->
<rect x="2.2" y="18.8" width="19.6" height="1.6" rx="0.8" fill="#A46D16" opacity="0.45"></rect>
<!-- Clasp -->
<path d="M6 9.5
v-4
a5 5 0 0 1 12 0
v4"
fill="none"
stroke="#C28A24"
stroke-width="2.4"
stroke-linecap="round"/>
v4" fill="none" stroke="#C28A24" stroke-width="2.4" stroke-linecap="round"></path>
<rect x="2.2" y="8.4" width="19.6" height="2.4"
rx="1.2" fill="#FFFFFF" opacity="0.22"/>
<!-- Top highlight band -->
<rect x="2.2" y="8.4" width="19.6" height="2.4" rx="1.2" fill="#FFFFFF" opacity="0.22"></rect>
<circle cx="12" cy="16" r="2.2" fill="#4A3210"/>
<rect x="11.3" y="16.8" width="1.4" height="3.1"
rx="0.7" fill="#2F1F08"/>
<!-- Keyhole -->
<circle cx="12" cy="16" r="2.2" fill="#4A3210"></circle>
<rect x="11.3" y="16.8" width="1.4" height="3.1" rx="0.7" fill="#2F1F08"></rect>
</g>
</g>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 5.7 KiB

After

Width:  |  Height:  |  Size: 5.9 KiB

View File

@@ -47,6 +47,15 @@ body {
border-bottom: none;
}
.card-link .card-header.text-center {
padding-top: 0.5rem !important;
padding-bottom: 0.5rem !important;
height: 4.5rem; /* Fixed height to match original */
display: flex;
align-items: center;
justify-content: center;
}
.feature-card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
@@ -255,3 +264,137 @@ footer {
.footer-icon {
vertical-align: text-bottom;
}
/* ----------------------------------------------------------------------------
Card Stuff Icons
---------------------------------------------------------------------------- */
.action-card {
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.action-card:hover {
transform: translateY(-5px);
box-shadow: 0 10px 20px rgba(0,0,0,0.1) !important;
}
.feature-card {
border-radius: 10px;
overflow: hidden;
}
/* Ensure buttons are easily tappable on mobile */
@media (max-width: 768px) {
.btn-lg {
padding: 1rem 1.5rem;
font-size: 1.1rem;
}
.card {
margin-bottom: 1rem;
}
}
/* ----------------------------------------------------------------------------
Dramatic Gold Glow Hover Effect
---------------------------------------------------------------------------- */
.embossed-icon {
color: #f8f8f8 !important;
text-shadow:
/* Subtle emboss only by default */
0 0.5px 0 rgba(255, 255, 255, 0.4), /* Soft top highlight */
0 -0.5px 0 rgba(0, 0, 0, 0.15), /* Very soft bottom shadow */
0 1px 2px rgba(0, 0, 0, 0.1); /* Minimal drop shadow */
position: relative;
display: inline-block;
transition: all 0.3s ease;
font-size: 3.5rem !important;
line-height: 1;
padding: 10px 0;
}
.card-link:hover .embossed-icon {
color: #ffffff !important;
text-shadow:
/* GOLD GLOW LAYERS - Only on hover */
0 0 10px rgba(255, 215, 0, 0.5), /* Soft inner glow */
0 0 20px rgba(255, 215, 0, 0.3), /* Medium glow */
0 0 30px rgba(255, 215, 0, 0.2), /* Outer glow */
0 0 40px rgba(255, 215, 0, 0.1), /* Far outer glow */
/* Enhanced emboss on hover */
0 1px 0 rgba(255, 255, 255, 0.8), /* Bright top highlight */
0 -1px 0 rgba(0, 0, 0, 0.25), /* Deeper bottom shadow */
0 2px 4px rgba(0, 0, 0, 0.15), /* Soft drop shadow */
/* Gold accent shadows */
0 1px 2px rgba(255, 215, 0, 0.3), /* Gold highlight layer */
0 -1px 1px rgba(255, 215, 0, 0.15); /* Gold shadow layer */
filter: drop-shadow(0 0 8px rgba(255, 215, 0, 0.25));
transform: scale(1.02); /* Slight grow on hover */
}
/* Card header adjustments for dramatic effect */
.card-link .card-header.text-center {
padding-top: 1.25rem !important;
padding-bottom: 1.25rem !important;
min-height: 6.5rem;
display: flex;
align-items: center;
justify-content: center;
position: relative;
transition: all 0.3s ease;
}
/* Enhance the gradient on hover for dramatic effect */
.card-link:hover .card-header.text-center {
background: linear-gradient(135deg,
var(--gradient-start) 0%,
#5a67d8 20%,
var(--gradient-end) 80%,
#8a2be2 100%);
box-shadow: inset 0 0 20px rgba(255, 215, 0, 0.1);
}
/* Mobile adjustments */
@media (max-width: 768px) {
.embossed-icon {
font-size: 2.8rem !important;
padding: 8px 0;
}
.card-link:hover .embossed-icon {
transform: scale(1.01);
}
.card-link .card-header.text-center {
padding-top: 1rem !important;
padding-bottom: 1rem !important;
min-height: 5.5rem;
}
}
/* ----------------------------------------------------------------------------
Card Links
---------------------------------------------------------------------------- */
.card-link {
text-decoration: none;
color: inherit;
display: block;
height: 100%;
}
.card-link:hover {
color: inherit;
}
/* Optional: Add a slight scale effect to the entire card on hover */
.card-link .feature-card {
transition: transform 0.3s ease, box-shadow 0.3s ease;
}
.card-link:hover .feature-card {
transform: translateY(-5px);
box-shadow: 0 10px 40px rgba(102, 126, 234, 0.3);
}

View File

@@ -10,37 +10,239 @@
<h5 class="mb-0"><i class="bi bi-info-circle me-2"></i>About Stegasoo</h5>
</div>
<div class="card-body">
<p>
Stegasoo is a hybrid steganography system that hides encrypted messages inside
ordinary images. It combines multiple security layers to create a system that is
both highly secure and practical to use.
<p class="lead">
Stegasoo is a secure steganography tool that hides encrypted messages and files
inside ordinary images using multi-factor authentication.
</p>
<h6 class="mt-4 mb-3">System Status</h6>
<div class="row g-3">
<h6 class="text-primary mt-4 mb-3"><i class="bi bi-stars me-2"></i>Key Features</h6>
<div class="row">
<div class="col-md-6">
<div class="d-flex align-items-center p-3 rounded status-box">
{% if has_argon2 %}
<i class="bi bi-check-circle-fill text-success fs-4 me-3"></i>
<div>
<strong>Argon2id Available</strong>
<div class="small text-muted">Memory-hard key derivation (256MB)</div>
</div>
{% else %}
<i class="bi bi-exclamation-triangle-fill text-warning fs-4 me-3"></i>
<div>
<strong>Using PBKDF2 Fallback</strong>
<div class="small text-muted">Install argon2-cffi for better security</div>
</div>
{% endif %}
</div>
<ul class="list-unstyled">
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Text &amp; File Embedding</strong> — Hide messages or any file type (PDF, ZIP, documents)
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Multi-Factor Security</strong> — Combines photo + phrase + PIN/RSA key
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>AES-256-GCM Encryption</strong> — Military-grade authenticated encryption
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Daily Rotating Phrases</strong> — Different passphrase each day of the week
</li>
</ul>
</div>
<div class="col-md-6">
<div class="d-flex align-items-center p-3 rounded status-box">
<i class="bi bi-shield-fill-check text-success fs-4 me-3"></i>
<div>
<strong>AES-256-GCM</strong>
<div class="small text-muted">Authenticated encryption enabled</div>
<ul class="list-unstyled">
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Random Pixel Embedding</strong> — Defeats statistical steganalysis
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Format Preservation</strong> — Maintains PNG/BMP lossless formats
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Large Capacity</strong> — Up to {{ max_payload_kb }} KB payload, 16MP images
</li>
<li class="mb-2">
<i class="bi bi-check-circle text-success me-2"></i>
<strong>Zero Server Storage</strong> — Nothing saved, files auto-expire
</li>
</ul>
</div>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>How Security Works</h5>
</div>
<div class="card-body">
<p>Stegasoo uses <strong>hybrid multi-factor authentication</strong> to derive encryption keys:</p>
<div class="row text-center my-4">
<div class="col-md-3 mb-3">
<div class="p-3 bg-dark rounded">
<i class="bi bi-image text-info fs-2 d-block mb-2"></i>
<strong>Reference Photo</strong>
<div class="small text-muted mt-1">Something you have</div>
<div class="small text-success">~80-256 bits</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="p-3 bg-dark rounded">
<i class="bi bi-chat-quote text-warning fs-2 d-block mb-2"></i>
<strong>Daily Phrase</strong>
<div class="small text-muted mt-1">Something you know (rotates)</div>
<div class="small text-success">~33 bits (3 words)</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="p-3 bg-dark rounded">
<i class="bi bi-123 text-danger fs-2 d-block mb-2"></i>
<strong>Static PIN</strong>
<div class="small text-muted mt-1">Something you know (fixed)</div>
<div class="small text-success">~20 bits (6 digits)</div>
</div>
</div>
<div class="col-md-3 mb-3">
<div class="p-3 bg-dark rounded">
<i class="bi bi-key text-primary fs-2 d-block mb-2"></i>
<strong>RSA Key</strong>
<div class="small text-muted mt-1">Something you have (optional)</div>
<div class="small text-success">~128 bits (2048-bit)</div>
</div>
</div>
</div>
<div class="alert alert-secondary">
<i class="bi bi-calculator me-2"></i>
<strong>Combined entropy:</strong> 130-400+ bits depending on configuration.
For reference, 128 bits is considered computationally infeasible to brute force.
</div>
<h6 class="mt-4">Key Derivation</h6>
<p>
{% if has_argon2 %}
<span class="badge bg-success me-1"><i class="bi bi-check"></i> Argon2id Available</span>
Using <strong>Argon2id</strong> with 256MB memory cost — the winner of the Password Hashing Competition
and current best practice for key derivation.
{% else %}
<span class="badge bg-warning text-dark me-1"><i class="bi bi-exclamation-triangle"></i> Argon2 Not Available</span>
Falling back to <strong>PBKDF2-SHA512</strong> with 600,000 iterations.
Install <code>argon2-cffi</code> for stronger security.
{% endif %}
</p>
<h6 class="mt-4">Steganography Technique</h6>
<p>
Uses <strong>LSB (Least Significant Bit)</strong> embedding with pseudo-random pixel selection.
The pixel locations are determined by a key derived from your credentials, making the
hidden data's location unpredictable without the correct inputs.
</p>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-file-earmark-binary me-2"></i>File Embedding</h5>
</div>
<div class="card-body">
<p>
<span class="badge bg-info me-1">New in v2.1</span>
Stegasoo now supports embedding <strong>any file type</strong>, not just text messages.
</p>
<div class="row">
<div class="col-md-6">
<h6><i class="bi bi-check2-square text-success me-2"></i>Supported</h6>
<ul class="small">
<li>PDF documents</li>
<li>ZIP/RAR archives</li>
<li>Office documents (DOCX, XLSX, PPTX)</li>
<li>Source code files</li>
<li>Any binary file up to {{ max_payload_kb }} KB</li>
</ul>
</div>
<div class="col-md-6">
<h6><i class="bi bi-info-circle text-info me-2"></i>How It Works</h6>
<ul class="small">
<li>Original filename is preserved</li>
<li>MIME type is stored for proper handling</li>
<li>File is encrypted identically to text</li>
<li>Decoding auto-detects text vs. file</li>
</ul>
</div>
</div>
<div class="alert alert-info small mt-3">
<i class="bi bi-lightbulb me-2"></i>
<strong>Tip:</strong> For larger files, compress them first (ZIP) to maximize capacity.
A 16MP carrier image can hold approximately 6MB of raw data, but we limit payloads
to {{ max_payload_kb }} KB for reasonable processing times.
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-question-circle me-2"></i>Usage Guide</h5>
</div>
<div class="card-body">
<div class="accordion" id="usageAccordion">
<div class="accordion-item bg-dark">
<h2 class="accordion-header">
<button class="accordion-button bg-dark text-light" type="button"
data-bs-toggle="collapse" data-bs-target="#setup">
<i class="bi bi-1-circle me-2"></i>Initial Setup
</button>
</h2>
<div id="setup" class="accordion-collapse collapse show" data-bs-parent="#usageAccordion">
<div class="accordion-body">
<ol>
<li>Both parties agree on a <strong>reference photo</strong> (shared secretly, never transmitted)</li>
<li>Go to <a href="/generate">Generate</a> and create credentials</li>
<li><strong>Memorize</strong> the 7 daily phrases and PIN</li>
<li>If using RSA, download and securely store the key file</li>
<li>Share credentials with your contact through a secure channel</li>
</ol>
</div>
</div>
</div>
<div class="accordion-item bg-dark">
<h2 class="accordion-header">
<button class="accordion-button collapsed bg-dark text-light" type="button"
data-bs-toggle="collapse" data-bs-target="#encoding">
<i class="bi bi-2-circle me-2"></i>Encoding a Message or File
</button>
</h2>
<div id="encoding" class="accordion-collapse collapse" data-bs-parent="#usageAccordion">
<div class="accordion-body">
<ol>
<li>Go to <a href="/encode">Encode</a></li>
<li>Upload your <strong>reference photo</strong></li>
<li>Upload a <strong>carrier image</strong> (the image to hide data in)</li>
<li>Choose <strong>Text</strong> or <strong>File</strong> mode</li>
<li>Enter your message or select a file to embed</li>
<li>Enter <strong>today's phrase</strong> and your PIN/key</li>
<li>Download the resulting stego image</li>
<li>Send the stego image through any channel (email, social media, etc.)</li>
</ol>
</div>
</div>
</div>
<div class="accordion-item bg-dark">
<h2 class="accordion-header">
<button class="accordion-button collapsed bg-dark text-light" type="button"
data-bs-toggle="collapse" data-bs-target="#decoding">
<i class="bi bi-3-circle me-2"></i>Decoding a Message or File
</button>
</h2>
<div id="decoding" class="accordion-collapse collapse" data-bs-parent="#usageAccordion">
<div class="accordion-body">
<ol>
<li>Go to <a href="/decode">Decode</a></li>
<li>Upload your <strong>reference photo</strong> (same one used for encoding)</li>
<li>Upload the <strong>stego image</strong> you received</li>
<li>Enter the phrase for <strong>the day it was encoded</strong> (check the filename for date)</li>
<li>Enter your PIN and/or RSA key</li>
<li>View the decoded message or download the extracted file</li>
</ol>
<div class="alert alert-warning small mt-3 mb-0">
<i class="bi bi-exclamation-triangle me-2"></i>
The stego image filename contains the encoding date (e.g., <code>abc123_20251228.png</code>).
Use this to determine which day's phrase to use!
</div>
</div>
</div>
</div>
@@ -50,129 +252,100 @@
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-shield-lock me-2"></i>Security Model</h5>
<h5 class="mb-0"><i class="bi bi-speedometer2 me-2"></i>Limits &amp; Specifications</h5>
</div>
<div class="card-body">
<div class="table-responsive">
<table class="table table-dark">
<thead>
<tr>
<th>Component</th>
<th>Entropy</th>
<th>Purpose</th>
</tr>
</thead>
<tbody>
<tr>
<td><i class="bi bi-image text-info me-2"></i>Reference Photo</td>
<td>~80-256 bits</td>
<td>Something you have (plausible deniability)</td>
</tr>
<tr>
<td><i class="bi bi-chat-quote text-info me-2"></i>3-Word Phrase</td>
<td>~33 bits</td>
<td>Something you know (changes daily)</td>
</tr>
<tr>
<td><i class="bi bi-123 text-info me-2"></i>6-Digit PIN</td>
<td>~20 bits</td>
<td>Something you know (static)</td>
</tr>
<tr>
<td><i class="bi bi-calendar text-info me-2"></i>Date</td>
<td>N/A</td>
<td>Automatic key rotation</td>
</tr>
<tr class="table-active">
<td><strong>Combined</strong></td>
<td><strong>133+ bits</strong></td>
<td><strong>Beyond brute force</strong></td>
</tr>
</tbody>
</table>
</div>
</div>
</div>
<div class="card mb-4">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-cpu me-2"></i>Attack Resistance</h5>
</div>
<div class="card-body">
<div class="row g-4">
<div class="col-md-6">
<h6 class="text-danger"><i class="bi bi-x-circle me-2"></i>What Attackers Can't Do</h6>
<ul class="list-unstyled">
<li class="mb-2">
<i class="bi bi-shield-x text-muted me-2"></i>
Brute force the passphrase (2<sup>133</sup> combinations)
</li>
<li class="mb-2">
<i class="bi bi-shield-x text-muted me-2"></i>
Use rainbow tables (random salt per message)
</li>
<li class="mb-2">
<i class="bi bi-shield-x text-muted me-2"></i>
Detect hidden data (random pixel selection)
</li>
<li class="mb-2">
<i class="bi bi-shield-x text-muted me-2"></i>
Use GPU farms (Argon2 requires 256MB RAM per attempt)
</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-warning"><i class="bi bi-exclamation-triangle me-2"></i>Real Threats</h6>
<ul class="list-unstyled">
<li class="mb-2">
<i class="bi bi-person-x text-muted me-2"></i>
Social engineering (someone tricks you)
</li>
<li class="mb-2">
<i class="bi bi-door-open text-muted me-2"></i>
Physical access to your devices
</li>
<li class="mb-2">
<i class="bi bi-bug text-muted me-2"></i>
Malware/keyloggers on your system
</li>
<li class="mb-2">
<i class="bi bi-camera-video text-muted me-2"></i>
Shoulder surfing while you type
</li>
</ul>
</div>
</div>
<table class="table table-dark table-striped">
<tbody>
<tr>
<td><i class="bi bi-file-text me-2"></i>Max text message</td>
<td><strong>250,000 characters</strong> (~250 KB)</td>
</tr>
<tr>
<td><i class="bi bi-file-earmark me-2"></i>Max file payload</td>
<td><strong>{{ max_payload_kb }} KB</strong></td>
</tr>
<tr>
<td><i class="bi bi-image me-2"></i>Max carrier image</td>
<td><strong>16 megapixels</strong> (~4000×4000)</td>
</tr>
<tr>
<td><i class="bi bi-upload me-2"></i>Max upload size</td>
<td><strong>10 MB</strong></td>
</tr>
<tr>
<td><i class="bi bi-clock me-2"></i>Temp file expiry</td>
<td><strong>5 minutes</strong></td>
</tr>
<tr>
<td><i class="bi bi-key me-2"></i>PIN length</td>
<td><strong>6-9 digits</strong></td>
</tr>
<tr>
<td><i class="bi bi-shield me-2"></i>RSA key sizes</td>
<td><strong>2048, 3072, 4096 bits</strong></td>
</tr>
<tr>
<td><i class="bi bi-chat-quote me-2"></i>Phrase length</td>
<td><strong>3-12 words</strong> (BIP-39 wordlist)</td>
</tr>
</tbody>
</table>
</div>
</div>
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-book me-2"></i>Best Practices</h5>
<h5 class="mb-0"><i class="bi bi-terminal me-2"></i>CLI &amp; API</h5>
</div>
<div class="card-body">
<div class="row">
<div class="col-md-6">
<h6 class="text-success"><i class="bi bi-check-lg me-2"></i>Do</h6>
<ul>
<li>Memorize your phrases and PIN, never write them down</li>
<li>Use a reference photo that both parties already have</li>
<li>Use different carrier images for each message</li>
<li>Share stego images through normal channels (looks innocent)</li>
</ul>
</div>
<div class="col-md-6">
<h6 class="text-danger"><i class="bi bi-x-lg me-2"></i>Don't</h6>
<ul>
<li>Don't transmit the reference photo</li>
<li>Don't reuse the same carrier image</li>
<li>Don't store phrases or PIN digitally</li>
<li>Don't resize or recompress stego images</li>
</ul>
</div>
</div>
<p>Stegasoo is also available as a command-line tool and REST API:</p>
<h6 class="mt-3">Command Line</h6>
<pre class="bg-dark p-3 rounded"><code># Generate credentials
stegasoo generate --pin --rsa
# Encode a text message
stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder" --pin 123456 -m "secret"
# Encode a file
stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder" --pin 123456 -e document.pdf
# Decode (auto-detects text vs file)
stegasoo decode -r photo.jpg -s stego.png -p "apple forest thunder" --pin 123456</code></pre>
<h6 class="mt-4">REST API</h6>
<pre class="bg-dark p-3 rounded"><code># Encode with multipart upload
curl -X POST http://localhost:8000/encode/multipart \
-F "reference_photo=@photo.jpg" \
-F "carrier=@meme.png" \
-F "message=secret" \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
--output stego.png
# Encode a file
curl -X POST http://localhost:8000/encode/multipart \
-F "reference_photo=@photo.jpg" \
-F "carrier=@meme.png" \
-F "payload_file=@document.pdf" \
-F "day_phrase=apple forest thunder" \
-F "pin=123456" \
--output stego.png</code></pre>
<p class="small text-muted mt-3 mb-0">
API documentation available at <code>/docs</code> (Swagger) or <code>/redoc</code> when running the API server.
</p>
</div>
</div>
<div class="text-center mt-4 text-muted small">
<p>
Stegasoo v2.1.0 &bull;
<i class="bi bi-github me-1"></i>Open Source &bull;
Built with Python, Flask, and cryptography
</p>
</div>
</div>
</div>
{% endblock %}

View File

@@ -63,7 +63,7 @@
<div class="container text-center text-muted">
<small>
<img src="{{ url_for('static', filename='favicon.svg') }}" alt="" height="16" class="me-1" style="vertical-align: text-bottom;">
Stegasoo v1.1 — Hybrid Photo + Day-Phrase + PIN Steganography
Stegasoo v2.1.0 — Hybrid Photo + Day-Phrase + PIN Steganography
</small>
</div>
</footer>

View File

@@ -7,25 +7,57 @@
<div class="col-lg-8">
<div class="card">
<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 class="card-body">
{% if decoded_message %}
<!-- Text Message Result -->
<div class="alert alert-success">
<h6><i class="bi bi-check-circle me-2"></i>Message Decrypted Successfully!</h6>
</div>
<label class="form-label text-muted">Decoded Message:</label>
<div class="position-relative">
<div class="alert-message p-3 rounded bg-dark border border-secondary" id="decodedContent" style="white-space: pre-wrap;">{{ decoded_message }}</div>
<button class="btn btn-sm btn-outline-light position-absolute top-0 end-0 m-2" onclick="navigator.clipboard.writeText(document.getElementById('decodedContent').innerText).then(() => this.innerHTML = '<i class=\'bi bi-check\'></i>').catch(() => alert('Failed to copy'))">
<i class="bi bi-clipboard"></i> Copy
</button>
</div>
<a href="/decode" class="btn btn-outline-light w-100 mt-3">
<i class="bi bi-arrow-repeat me-2"></i>Decode Another
</a>
<div class="mb-4">
<label class="form-label text-muted">Decoded Message:</label>
<div class="alert-message">{{ decoded_message }}</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">
<i class="bi bi-arrow-repeat me-2"></i>Decode Another Message
<i class="bi bi-arrow-repeat me-2"></i>Decode Another
</a>
{% else %}
<!-- Decode Form -->
<form method="POST" enctype="multipart/form-data" id="decodeForm">
<div class="row">
<div class="col-md-6 mb-3">
@@ -58,7 +90,7 @@
<img class="drop-zone-preview d-none">
</div>
<div class="form-text">
The image containing the hidden message
The image containing the hidden message/file
</div>
</div>
</div>
@@ -83,24 +115,47 @@
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">
<i class="bi bi-123 me-1"></i> PIN
</label>
<input type="password" name="pin" class="form-control" id="pinInput"
placeholder="6-9 digits" maxlength="9">
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
<div class="input-group">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="6-9 digits" maxlength="9">
<button class="btn btn-outline-secondary" type="button" id="togglePin">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="form-text">
If PIN was used during encoding
</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
</label>
<input type="file" name="rsa_key" class="form-control" id="rsaKeyInput"
accept=".pem,.key">
<ul class="nav nav-tabs nav-tabs-sm mb-2" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaFileTabDec" type="button">
<i class="bi bi-file-earmark me-1"></i>.pem File
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaQrTabDec" type="button">
<i class="bi bi-qr-code me-1"></i>QR Code
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="rsaFileTabDec" role="tabpanel">
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
</div>
<div class="tab-pane fade" id="rsaQrTabDec" role="tabpanel">
<input type="file" name="rsa_key_qr" class="form-control form-control-sm" id="rsaKeyQrInput" accept="image/*">
<div class="form-text small">PNG, JPG, or other image of QR code</div>
</div>
</div>
<div class="form-text">
If RSA key was used during encoding
If RSA key was used during encoding (file or QR image)
</div>
</div>
</div>
@@ -118,7 +173,7 @@
</div>
<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>
</form>
@@ -126,6 +181,7 @@
</div>
</div>
{% if not decoded_message and not decoded_file %}
<div class="card mt-4">
<div class="card-body">
<h6 class="text-muted mb-3"><i class="bi bi-question-circle me-2"></i>Troubleshooting</h6>
@@ -153,6 +209,7 @@
</ul>
</div>
</div>
{% endif %}
</div>
</div>
{% endblock %}
@@ -166,13 +223,32 @@ document.getElementById('decodeForm')?.addEventListener('submit', function() {
btn.disabled = true;
});
// Show RSA password field when key is selected
// Show RSA password field when key is selected (only for .pem files, not QR)
const rsaKeyInput = document.getElementById('rsaKeyInput');
const rsaKeyQrInput = document.getElementById('rsaKeyQrInput');
const rsaPasswordGroup = document.getElementById('rsaPasswordGroup');
rsaKeyInput?.addEventListener('change', function() {
rsaPasswordGroup.classList.toggle('d-none', !this.files.length);
});
if (rsaKeyInput) {
rsaKeyInput.addEventListener('change', function() {
// Show password field only for .pem files
rsaPasswordGroup.classList.toggle('d-none', !this.files.length);
// Clear QR input if file is selected
if (rsaKeyQrInput && this.files.length) {
rsaKeyQrInput.value = '';
}
});
}
if (rsaKeyQrInput) {
rsaKeyQrInput.addEventListener('change', function() {
// Hide password field for QR codes (they're unencrypted)
rsaPasswordGroup.classList.add('d-none');
// Clear file input if QR is selected
if (rsaKeyInput && this.files.length) {
rsaKeyInput.value = '';
}
});
}
// Day names for date detection
const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday'];
@@ -197,6 +273,43 @@ function updateDayLabel(dayName) {
}
}
// PIN Toggle
document.getElementById('togglePin')?.addEventListener('click', function() {
const input = document.getElementById('pinInput');
const icon = this.querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.classList.replace('bi-eye', 'bi-eye-slash');
} else {
input.type = 'password';
icon.classList.replace('bi-eye-slash', 'bi-eye');
}
});
// Paste from Clipboard
document.addEventListener('paste', function(e) {
if (!document.getElementById('decodeForm')) return;
const items = e.clipboardData.items;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image") !== -1) {
const blob = items[i].getAsFile();
const stegoInput = document.querySelector('input[name="stego_image"]');
const refInput = document.querySelector('input[name="reference_photo"]');
const targetInput = (!stegoInput.files.length) ? stegoInput : refInput;
const container = new DataTransfer();
container.items.add(blob);
targetInput.files = container.files;
targetInput.dispatchEvent(new Event('change'));
break;
}
}
});
// Drag & drop with preview
document.querySelectorAll('.drop-zone').forEach(zone => {
const input = zone.querySelector('input[type="file"]');

View File

@@ -7,7 +7,7 @@
<div class="col-lg-8">
<div class="card">
<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 class="card-body">
<form method="POST" enctype="multipart/form-data" id="encodeForm">
@@ -49,15 +49,34 @@
</div>
</div>
<!-- Payload Type Selector -->
<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">
<i class="bi bi-chat-left-text me-1"></i> Secret Message
</label>
<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">
<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">
<i class="bi bi-exclamation-triangle"></i> Getting long!
</span>
@@ -66,6 +85,29 @@
</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">
<label class="form-label" id="dayPhraseLabel">
<i class="bi bi-chat-quote me-1"></i> {{ day_of_week }}'s Phrase
@@ -86,24 +128,41 @@
<div class="row">
<div class="col-md-6 mb-3">
<label class="form-label">
<i class="bi bi-123 me-1"></i> PIN
</label>
<input type="password" name="pin" class="form-control" id="pinInput"
placeholder="6-9 digits" maxlength="9">
<div class="form-text">
Your static 6-9 digit PIN (if configured)
</div>
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
<div class="input-group">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="6-9 digits" maxlength="9">
<button class="btn btn-outline-secondary" type="button" id="togglePin">
<i class="bi bi-eye"></i>
</button>
</div>
<div class="form-text">Your static 6-9 digit PIN (if configured)</div>
</div>
<div class="col-md-6 mb-3">
<label class="form-label">
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
</label>
<input type="file" name="rsa_key" class="form-control" id="rsaKeyInput"
accept=".pem,.key">
<div class="form-text">
Your shared .pem key file (if configured)
<ul class="nav nav-tabs nav-tabs-sm mb-2" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaFileTab" type="button">
<i class="bi bi-file-earmark me-1"></i>.pem File
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link py-1 px-2 small" data-bs-toggle="tab" data-bs-target="#rsaQrTab" type="button">
<i class="bi bi-qr-code me-1"></i>QR Code
</button>
</li>
</ul>
<div class="tab-content">
<div class="tab-pane fade show active" id="rsaFileTab" role="tabpanel">
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
<div class="form-text small">Shared .pem format key file.</div>
</div>
<div class="tab-pane fade" id="rsaQrTab" role="tabpanel">
<input type="file" name="rsa_key_qr" class="form-control form-control-sm" id="rsaKeyQrInput" accept="image/*">
<div class="form-text small">PNG, JPG, or other image of QR code</div>
</div>
</div>
</div>
</div>
@@ -116,15 +175,15 @@
<input type="password" name="rsa_password" class="form-control"
placeholder="Password for the .pem file (if encrypted)">
<div class="form-text">
Leave blank if your key file is not password-protected
Leave blank if your key file is not password-protected (not needed for QR codes)
</div>
</div>
<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>
</form>
<hr class="my-4">
<div class="row text-center text-muted small">
@@ -146,8 +205,8 @@
<i class="bi bi-info-circle me-1"></i>
<strong>Limits:</strong>
Carrier image max ~4 megapixels (2000×2000).
Files max 5MB each.
Message max 50KB.
Files max 10MB upload.
Payload max {{ max_payload_kb }} KB.
</div>
</div>
</div>
@@ -177,13 +236,83 @@ if (dateInput) {
dateInput.value = localDate;
}
// Show RSA password field when key is selected
// 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 (only for .pem files, not QR)
const rsaKeyInput = document.getElementById('rsaKeyInput');
const rsaKeyQrInput = document.getElementById('rsaKeyQrInput');
const rsaPasswordGroup = document.getElementById('rsaPasswordGroup');
rsaKeyInput.addEventListener('change', function() {
rsaPasswordGroup.classList.toggle('d-none', !this.files.length);
});
if (rsaKeyInput) {
rsaKeyInput.addEventListener('change', function() {
// Show password field only for .pem files
rsaPasswordGroup.classList.toggle('d-none', !this.files.length);
// Clear QR input if file is selected
if (rsaKeyQrInput && this.files.length) {
rsaKeyQrInput.value = '';
}
});
}
if (rsaKeyQrInput) {
rsaKeyQrInput.addEventListener('change', function() {
// Hide password field for QR codes (they're unencrypted)
rsaPasswordGroup.classList.add('d-none');
// Clear file input if QR is selected
if (rsaKeyInput && this.files.length) {
rsaKeyInput.value = '';
}
});
}
// Form submit loading state
document.getElementById('encodeForm').addEventListener('submit', function(e) {
@@ -192,12 +321,11 @@ document.getElementById('encodeForm').addEventListener('submit', function(e) {
btn.disabled = true;
});
// Character counter
const messageInput = document.getElementById('messageInput');
// Character counter for text
const charCount = document.getElementById('charCount');
const charWarning = document.getElementById('charWarning');
const charPercent = document.getElementById('charPercent');
const maxChars = 50000;
const maxChars = 250000;
messageInput.addEventListener('input', function() {
const len = this.value.length;
@@ -210,11 +338,12 @@ messageInput.addEventListener('input', function() {
charCount.classList.toggle('text-danger', len > maxChars * 0.95);
});
// Drag & drop with preview
// Drag & drop with preview for images
document.querySelectorAll('.drop-zone').forEach(zone => {
const input = zone.querySelector('input[type="file"]');
const label = zone.querySelector('.drop-zone-label');
const preview = zone.querySelector('.drop-zone-preview');
const isPayloadZone = zone.id === 'payloadDropZone';
['dragenter', 'dragover'].forEach(evt => {
zone.addEventListener(evt, e => {
@@ -233,27 +362,90 @@ document.querySelectorAll('.drop-zone').forEach(zone => {
zone.addEventListener('drop', e => {
if (e.dataTransfer.files.length) {
input.files = e.dataTransfer.files;
showPreview(e.dataTransfer.files[0]);
input.dispatchEvent(new Event('change'));
if (!isPayloadZone) {
showPreview(e.dataTransfer.files[0]);
}
}
});
input.addEventListener('change', function() {
if (this.files && this.files[0]) {
showPreview(this.files[0]);
}
});
if (!isPayloadZone) {
input.addEventListener('change', function() {
if (this.files && this.files[0]) {
showPreview(this.files[0]);
}
});
}
function showPreview(file) {
if (!file.type.startsWith('image/')) return;
const reader = new FileReader();
reader.onload = e => {
preview.src = e.target.result;
preview.classList.remove('d-none');
if (preview) {
preview.src = e.target.result;
preview.classList.remove('d-none');
}
label.innerHTML = '<i class="bi bi-check-circle text-success me-1"></i>' + file.name;
};
reader.readAsDataURL(file);
}
});
// PIN Toggle Logic
document.getElementById('togglePin').addEventListener('click', function() {
const input = document.getElementById('pinInput');
const icon = this.querySelector('i');
if (input.type === 'password') {
input.type = 'text';
icon.classList.replace('bi-eye', 'bi-eye-slash');
} else {
input.type = 'password';
icon.classList.replace('bi-eye-slash', 'bi-eye');
}
});
// Prevent Same File Selection
function checkDuplicateFiles() {
const refInput = document.querySelector('input[name="reference_photo"]');
const carInput = document.querySelector('input[name="carrier"]');
if (refInput.files[0] && carInput.files[0]) {
if (refInput.files[0].name === carInput.files[0].name &&
refInput.files[0].size === carInput.files[0].size) {
alert("Security Warning: You cannot use the same image for both Reference and Carrier!");
carInput.value = '';
document.getElementById('carrierPreview').classList.add('d-none');
document.querySelector('#carrierDropZone .drop-zone-label').innerHTML =
'<i class="bi bi-cloud-arrow-up fs-3 d-block mb-2 text-muted"></i>' +
'<span class="text-muted">Drop image or click to browse</span>';
}
}
}
document.querySelector('input[name="reference_photo"]').addEventListener('change', checkDuplicateFiles);
document.querySelector('input[name="carrier"]').addEventListener('change', checkDuplicateFiles);
// Paste from Clipboard
document.addEventListener('paste', function(e) {
const items = e.clipboardData.items;
for (let i = 0; i < items.length; i++) {
if (items[i].type.indexOf("image") !== -1) {
const blob = items[i].getAsFile();
const carrierInput = document.querySelector('input[name="carrier"]');
const refInput = document.querySelector('input[name="reference_photo"]');
const targetInput = (!carrierInput.files.length) ? carrierInput : refInput;
const container = new DataTransfer();
container.items.add(blob);
targetInput.files = container.files;
targetInput.dispatchEvent(new Event('change'));
break;
}
}
});
</script>
{% endblock %}

View File

@@ -1,61 +1,64 @@
{% extends "base.html" %}
{% block title %}Message Encoded - Stegasoo{% endblock %}
{% block title %}Encode Success - Stegasoo{% endblock %}
{% block content %}
<div class="row justify-content-center">
<div class="col-lg-8">
<div class="col-lg-6">
<div class="card">
<div class="card-header">
<h5 class="mb-0"><i class="bi bi-check-circle-fill me-2"></i>Message Encoded Successfully!</h5>
<div class="card-header bg-success text-white">
<h5 class="mb-0"><i class="bi bi-check-circle me-2"></i>Encoding Successful!</h5>
</div>
<div class="card-body text-center">
<div class="mb-4">
<i class="bi bi-file-earmark-image text-success result-icon"></i>
<h5 class="mt-3">{{ filename }}</h5>
<p class="text-muted">Your secret message is hidden in this image</p>
<div class="my-4">
{% if thumbnail_url %}
<!-- Thumbnail of the actual encoded image -->
<div class="encoded-image-thumbnail">
<img src="{{ thumbnail_url }}"
alt="Encoded image thumbnail"
class="img-thumbnail rounded"
style="max-width: 250px; max-height: 250px; object-fit: contain;">
<div class="mt-2 small text-muted">
<i class="bi bi-image me-1"></i>Encoded Image Preview
</div>
</div>
{% else %}
<!-- Fallback to icon if thumbnail not available -->
<i class="bi bi-file-earmark-image text-success" style="font-size: 4rem;"></i>
{% endif %}
</div>
<div class="d-grid gap-3 mb-4">
<p class="lead mb-4">Your secret has been hidden in the image.</p>
<div class="mb-3">
<code class="fs-5">{{ filename }}</code>
</div>
<div class="d-grid gap-2">
<a href="{{ url_for('encode_download', file_id=file_id) }}"
class="btn btn-primary btn-lg" id="downloadBtn">
<i class="bi bi-download me-2"></i>Download Image
</a>
<button type="button" class="btn btn-outline-light btn-lg" id="shareBtn">
<i class="bi bi-share me-2"></i>Share Image
<button type="button" class="btn btn-outline-primary" id="shareBtn" style="display: none;">
<i class="bi bi-share me-2"></i>Share
</button>
</div>
<!-- Fallback share options (shown if Web Share API unavailable) -->
<div id="shareFallback" class="d-none">
<p class="text-muted mb-3">Share via:</p>
<div class="d-flex justify-content-center gap-2 flex-wrap">
<a href="#" id="shareEmail" class="btn btn-outline-secondary">
<i class="bi bi-envelope me-1"></i>Email
</a>
<a href="#" id="shareTelegram" class="btn btn-outline-secondary">
<i class="bi bi-telegram me-1"></i>Telegram
</a>
<a href="#" id="shareWhatsapp" class="btn btn-outline-secondary">
<i class="bi bi-whatsapp me-1"></i>WhatsApp
</a>
<button type="button" id="copyLink" class="btn btn-outline-secondary">
<i class="bi bi-link-45deg me-1"></i>Copy Link
</button>
</div>
</div>
<hr class="my-4">
<div class="alert alert-warning small text-start">
<i class="bi bi-clock me-1"></i>
<strong>File expires in 5 minutes.</strong>
Download or share now. The file will be securely deleted after expiry.
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>Important:</strong>
<ul class="mb-0 mt-2">
<li>This file expires in <strong>5 minutes</strong></li>
<li>Do <strong>not</strong> resize or recompress the image</li>
<li>PNG format preserves your hidden data</li>
</ul>
</div>
<a href="{{ url_for('encode_page') }}" class="btn btn-outline-light">
<i class="bi bi-plus-circle me-2"></i>Encode Another Message
<a href="{{ url_for('encode_page') }}" class="btn btn-outline-secondary">
<i class="bi bi-arrow-repeat me-2"></i>Encode Another Message
</a>
</div>
</div>
@@ -65,78 +68,42 @@
{% block scripts %}
<script>
const fileId = "{{ file_id }}";
const filename = "{{ filename }}";
const fileUrl = "{{ url_for('encode_file', file_id=file_id, _external=True) }}";
const downloadUrl = "{{ url_for('encode_download', file_id=file_id, _external=True) }}";
// Web Share API support
const shareBtn = document.getElementById('shareBtn');
const shareFallback = document.getElementById('shareFallback');
const fileUrl = "{{ url_for('encode_file_route', file_id=file_id, _external=True) }}";
const fileName = "{{ filename }}";
// Check if Web Share API with files is supported
async function canShareFiles() {
if (!navigator.canShare) return false;
// Create a test file to check
const testFile = new File(['test'], 'test.png', { type: 'image/png' });
return navigator.canShare({ files: [testFile] });
if (navigator.share && navigator.canShare) {
// Check if we can share files
fetch(fileUrl)
.then(response => response.blob())
.then(blob => {
const file = new File([blob], fileName, { type: 'image/png' });
if (navigator.canShare({ files: [file] })) {
shareBtn.style.display = 'block';
shareBtn.addEventListener('click', async () => {
try {
await navigator.share({
files: [file],
title: 'Stegasoo Image',
});
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Share failed:', err);
}
}
});
}
})
.catch(err => console.log('Could not load file for sharing'));
}
shareBtn.addEventListener('click', async function() {
const canShare = await canShareFiles();
if (canShare) {
try {
// Fetch the image as a blob
const response = await fetch(fileUrl);
const blob = await response.blob();
const file = new File([blob], filename, { type: 'image/png' });
await navigator.share({
files: [file],
title: 'Shared Image',
});
// Cleanup after successful share
fetch("{{ url_for('encode_cleanup', file_id=file_id) }}", { method: 'POST' });
} catch (err) {
if (err.name !== 'AbortError') {
console.error('Share failed:', err);
shareFallback.classList.remove('d-none');
}
}
} else {
// Show fallback options
shareFallback.classList.remove('d-none');
}
});
// Fallback share links
document.getElementById('shareEmail').href =
`mailto:?subject=Shared Image&body=Check out this image: ${downloadUrl}`;
document.getElementById('shareTelegram').href =
`https://t.me/share/url?url=${encodeURIComponent(downloadUrl)}`;
document.getElementById('shareWhatsapp').href =
`https://wa.me/?text=${encodeURIComponent('Check this out: ' + downloadUrl)}`;
document.getElementById('copyLink').addEventListener('click', function() {
navigator.clipboard.writeText(downloadUrl).then(() => {
this.innerHTML = '<i class="bi bi-check me-1"></i>Copied!';
setTimeout(() => {
this.innerHTML = '<i class="bi bi-link-45deg me-1"></i>Copy Link';
}, 2000);
});
});
// Cleanup after download
// Auto-cleanup after download
document.getElementById('downloadBtn').addEventListener('click', function() {
// Give time for download to start, then cleanup
setTimeout(() => {
fetch("{{ url_for('encode_cleanup', file_id=file_id) }}", { method: 'POST' });
}, 3000);
}, 2000);
});
</script>
{% endblock %}
{% endblock %}

View File

@@ -11,335 +11,479 @@
</div>
<div class="card-body">
{% if not generated %}
<p class="text-muted mb-4">
Generate your weekly phrase card and security factors. You must choose at least one: PIN or RSA Key.
</p>
<form method="POST" id="generateForm">
<!-- Generation Form -->
<form method="POST">
<div class="mb-4">
<label class="form-label">Words per phrase</label>
<select name="words_per_phrase" class="form-select" id="wordsSelect">
<option value="3" selected>3 words (~33 bits)</option>
<option value="4">4 words (~44 bits)</option>
<option value="5">5 words (~55 bits)</option>
<option value="6">6 words (~66 bits)</option>
<option value="7">7 words (~77 bits)</option>
<option value="8">8 words (~88 bits)</option>
<option value="9">9 words (~99 bits)</option>
<option value="10">10 words (~110 bits)</option>
<option value="11">11 words (~121 bits)</option>
<option value="12">12 words (~132 bits)</option>
</select>
<div class="form-text">More words = more security, harder to memorize</div>
<label class="form-label">Words per Phrase</label>
<input type="range" class="form-range" name="words_per_phrase"
min="3" max="12" value="3" id="wordsRange">
<div class="d-flex justify-content-between small text-muted">
<span>3 (33 bits)</span>
<span id="wordsValue" class="text-primary fw-bold">3 words (~33 bits)</span>
<span>12 (132 bits)</span>
</div>
</div>
<hr class="my-4">
<hr>
<h6 class="text-muted mb-3">SECURITY FACTORS <span class="text-warning">(select at least one)</span></h6>
<h6 class="text-muted mb-3">SECURITY FACTORS <span class="text-warning small">(select at least one)</span></h6>
<!-- PIN Option -->
<div class="card mb-3" style="background: rgba(0,0,0,0.2);">
<div class="card-body">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="use_pin" id="usePin" checked>
<label class="form-check-label fw-bold" for="usePin">
<i class="bi bi-123 me-1"></i> PIN
<div class="row">
<div class="col-md-6 mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="use_pin"
id="usePinCheck" checked>
<label class="form-check-label" for="usePinCheck">
<i class="bi bi-123 me-1"></i> Generate PIN
</label>
</div>
<div id="pinOptions">
<label class="form-label">PIN length</label>
<select name="pin_length" class="form-select" id="pinSelect">
<div class="mt-2" id="pinOptions">
<label class="form-label small">PIN Length</label>
<select name="pin_length" class="form-select form-select-sm">
<option value="6" selected>6 digits (~20 bits)</option>
<option value="7">7 digits (~23 bits)</option>
<option value="8">8 digits (~27 bits)</option>
<option value="8">8 digits (~26 bits)</option>
<option value="9">9 digits (~30 bits)</option>
</select>
<div class="form-text">Memorizable, same PIN used every day</div>
</div>
</div>
</div>
<!-- RSA Key Option -->
<div class="card mb-3" style="background: rgba(0,0,0,0.2);">
<div class="card-body">
<div class="form-check mb-3">
<input class="form-check-input" type="checkbox" name="use_rsa" id="useRsa">
<label class="form-check-label fw-bold" for="useRsa">
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
<div class="col-md-6 mb-3">
<div class="form-check form-switch">
<input class="form-check-input" type="checkbox" name="use_rsa"
id="useRsaCheck">
<label class="form-check-label" for="useRsaCheck">
<i class="bi bi-file-earmark-lock me-1"></i> Generate RSA Key
</label>
</div>
<div id="rsaOptions" class="d-none">
<label class="form-label">Key size</label>
<select name="rsa_bits" class="form-select" id="rsaSelect">
<option value="2048" selected>2048-bit (~128 bits effective)</option>
<option value="3072">3072-bit (~128 bits effective)</option>
<option value="4096">4096-bit (~128 bits effective)</option>
<div class="mt-2 d-none" id="rsaOptions">
<label class="form-label small">Key Size</label>
<select name="rsa_bits" class="form-select form-select-sm">
<option value="2048" selected>2048 bits (~128 bits entropy)</option>
<option value="3072">3072 bits (~128 bits entropy)</option>
<option value="4096">4096 bits (~128 bits entropy)</option>
</select>
<div class="form-text">File-based key, both parties need the same .pem file</div>
</div>
</div>
</div>
<div class="alert alert-info mb-4">
<div class="d-flex justify-content-between align-items-center">
<span><i class="bi bi-calculator me-2"></i>Estimated entropy:</span>
<strong id="entropyDisplay">~53 bits</strong>
</div>
<div class="progress mt-2" style="height: 8px;">
<div class="progress-bar bg-success" id="entropyBar" style="width: 40%"></div>
</div>
<small class="text-muted mt-1 d-block">
<span id="entropyDesc">Good for most use cases</span>
• Reference photo adds ~80-256 bits more
</small>
</div>
<div class="alert alert-warning d-none" id="noFactorWarning">
<i class="bi bi-exclamation-triangle me-2"></i>
You must select at least one security factor (PIN or RSA Key)
</div>
<button type="submit" class="btn btn-primary btn-lg w-100" id="generateBtn">
<button type="submit" class="btn btn-primary btn-lg w-100 mt-3">
<i class="bi bi-shuffle me-2"></i>Generate Credentials
</button>
</form>
{% else %}
<!-- Generated Results -->
<div class="alert alert-success-bright alert-dismissible fade show">
<i class="bi bi-check-circle me-2"></i>
<strong>Credentials Generated!</strong>
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
</div>
<div class="alert alert-warning alert-dismissible fade show">
<!-- Generated Credentials Display -->
<div class="alert alert-warning">
<i class="bi bi-exclamation-triangle me-2"></i>
<strong>Memorize phrases, save key securely, then close!</strong> - Do not screenshot
<button type="button" class="btn-close" data-bs-dismiss="alert"></button>
<strong>Memorize these credentials!</strong> They will not be shown again.
<br><small>Do not screenshot or save to an unencrypted file.</small>
</div>
{% if pin %}
<hr class="my-4">
<div class="text-center mb-4">
<h6 class="text-muted mb-2">YOUR STATIC PIN</h6>
<div class="pin-container">
<div class="pin-display">{{ pin }}</div>
</div>
<div class="mt-2">
<small class="text-muted">Use this {{ pin_length }}-digit PIN every day</small>
</div>
</div>
{% endif %}
{% if rsa_key_pem %}
<hr class="my-4">
<div class="mb-4">
<h6 class="text-muted mb-3">
<i class="bi bi-file-earmark-lock me-2"></i>YOUR RSA KEY ({{ rsa_bits }}-bit)
</h6>
<div class="alert alert-danger small">
<i class="bi bi-shield-exclamation me-1"></i>
<strong>Save this key securely!</strong> Share it with your recipient through a secure channel. You cannot recover it later.
</div>
<!-- Key Display -->
<div class="mb-3">
<textarea class="form-control font-monospace" id="rsaKeyText" rows="6" readonly style="font-size: 0.75rem;">{{ rsa_key_pem }}</textarea>
</div>
<!-- Copy to Clipboard -->
<button type="button" class="btn btn-outline-light me-2" id="copyKeyBtn">
<i class="bi bi-clipboard me-1"></i> Copy to Clipboard
</button>
<!-- Download with Password -->
<button type="button" class="btn btn-outline-light" data-bs-toggle="collapse" data-bs-target="#downloadKeyForm">
<i class="bi bi-download me-1"></i> Download as .pem
</button>
<div class="collapse mt-3" id="downloadKeyForm">
<div class="card" style="background: rgba(0,0,0,0.2);">
<div class="card-body">
<form method="POST" action="{{ url_for('download_key') }}">
<input type="hidden" name="key_pem" value="{{ rsa_key_pem }}">
<div class="mb-3">
<label class="form-label">Password to protect key file</label>
<input type="password" name="key_password" class="form-control"
placeholder="Minimum 8 characters" minlength="8" required>
<div class="form-text">You'll need this password when using the key</div>
</div>
<button type="submit" class="btn btn-primary">
<i class="bi bi-file-earmark-lock me-1"></i> Download Protected Key
</button>
</form>
<h6 class="text-muted"><i class="bi bi-123 me-2"></i>STATIC PIN</h6>
<div class="text-center">
<div class="pin-container d-inline-block">
<div class="pin-digits-row" id="pinDigits">
{% for digit in pin %}
<span class="pin-digit-box">{{ digit }}</span>
{% endfor %}
</div>
<div class="pin-buttons mt-3">
<button type="button" class="btn btn-sm btn-outline-secondary me-2" onclick="togglePinVisibility()">
<i class="bi bi-eye-slash" id="pinToggleIcon"></i>
<span id="pinToggleText">Hide</span>
</button>
<button type="button" class="btn btn-sm btn-outline-secondary" onclick="copyPin()">
<i class="bi bi-clipboard" id="pinCopyIcon"></i>
<span id="pinCopyText">Copy</span>
</button>
</div>
</div>
</div>
</div>
{% endif %}
<hr class="my-4">
<h6 class="text-muted mb-3">DAILY PHRASES ({{ words_per_phrase }} words each)</h6>
<div class="table-responsive">
<table class="table table-dark table-hover">
<thead>
<tr>
<th style="width: 140px;">Day</th>
<th>Phrase</th>
</tr>
</thead>
<tbody>
{% for day in days %}
<tr>
<td class="text-nowrap">
<i class="bi bi-calendar3 me-2"></i>{{ day }}
</td>
<td>
<span class="phrase-display">{{ phrases[day] }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
<div class="mb-4">
<h6 class="text-muted"><i class="bi bi-chat-quote me-2"></i>DAILY PHRASES</h6>
<div class="table-responsive">
<table class="table table-dark table-striped mb-0">
<tbody>
{% for day in days %}
<tr>
<td class="text-muted" style="width: 100px;">{{ day }}</td>
<td>
<span class="font-monospace phrase-display" id="phrase{{ loop.index }}">{{ phrases[day] }}</span>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
<div class="text-end mt-2">
<button class="btn btn-sm btn-outline-secondary" onclick="toggleAllPhrases()">
<i class="bi bi-eye-slash me-1"></i>Toggle Visibility
</button>
</div>
</div>
<div class="alert alert-success mt-4">
<h6><i class="bi bi-shield-check me-2"></i>Security Summary</h6>
<div class="row text-center mt-3">
<div class="col-3">
<div class="fs-4 fw-bold">{{ phrase_entropy }}</div>
<small class="text-muted">bits/phrase</small>
{% if rsa_key_pem %}
<div class="mb-4">
<h6 class="text-muted"><i class="bi bi-file-earmark-lock me-2"></i>RSA PRIVATE KEY ({{ rsa_bits }} bits)</h6>
<ul class="nav nav-tabs" role="tablist">
<li class="nav-item" role="presentation">
<button class="nav-link active" data-bs-toggle="tab" data-bs-target="#keyTextTab" type="button">
<i class="bi bi-file-text me-1"></i>PEM Text
</button>
</li>
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#keyDownloadTab" type="button">
<i class="bi bi-download me-1"></i>Download
</button>
</li>
{% if has_qrcode and qr_token %}
<li class="nav-item" role="presentation">
<button class="nav-link" data-bs-toggle="tab" data-bs-target="#keyQrTab" type="button">
<i class="bi bi-qr-code me-1"></i>QR Code
</button>
</li>
{% endif %}
</ul>
<div class="tab-content border border-top-0 rounded-bottom p-3 bg-dark">
<!-- PEM Text Tab -->
<div class="tab-pane fade show active" id="keyTextTab" role="tabpanel">
<pre class="bg-black p-2 rounded small mb-2" style="max-height: 200px; overflow-y: auto;"><code id="rsaKeyDisplay">{{ rsa_key_pem }}</code></pre>
<button class="btn btn-sm btn-outline-light"
onclick="navigator.clipboard.writeText(document.getElementById('rsaKeyDisplay').textContent)">
<i class="bi bi-clipboard me-1"></i>Copy to Clipboard
</button>
</div>
{% if pin %}
<div class="col-3">
<div class="fs-4 fw-bold">{{ pin_entropy }}</div>
<small class="text-muted">bits/PIN</small>
<!-- Download Tab -->
<div class="tab-pane fade" id="keyDownloadTab" role="tabpanel">
<form action="{{ url_for('download_key') }}" method="POST" class="row g-2 align-items-end">
<input type="hidden" name="key_pem" value="{{ rsa_key_pem }}">
<div class="col-md-8">
<label class="form-label small">Password to encrypt the key file</label>
<input type="password" name="key_password" class="form-control"
placeholder="Min 8 characters" minlength="8" required>
</div>
<div class="col-md-4">
<button type="submit" class="btn btn-primary w-100">
<i class="bi bi-download me-1"></i>Download .pem
</button>
</div>
</form>
<div class="form-text mt-2">
The downloaded file will be password-protected (AES-256 encrypted).
</div>
</div>
{% if has_qrcode and qr_token %}
<!-- QR Code Tab -->
<div class="tab-pane fade" id="keyQrTab" role="tabpanel">
<div class="text-center">
<p class="small text-muted mb-3">
Scan this QR code to transfer the RSA key to another device.
<br><strong>Warning:</strong> This is the unencrypted private key!
</p>
<div class="qr-container d-inline-block p-3 bg-white rounded mb-3">
<img src="{{ url_for('generate_qr', token=qr_token) }}"
alt="RSA Key QR Code"
class="img-fluid"
style="max-width: 300px;"
id="qrCodeImage">
</div>
<div>
<a href="{{ url_for('generate_qr_download', token=qr_token) }}"
class="btn btn-outline-primary">
<i class="bi bi-download me-1"></i>Download QR Code
</a>
<button class="btn btn-outline-secondary ms-2" onclick="printQrCode()">
<i class="bi bi-printer me-1"></i>Print
</button>
</div>
<div class="alert alert-warning small mt-3 mb-0 text-start">
<i class="bi bi-shield-exclamation me-1"></i>
<strong>Security note:</strong> The QR code contains your unencrypted private key.
Only scan in a secure environment. Consider using the password-protected download instead.
{% if rsa_bits >= 4096 %}
<br><br>
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>4096-bit keys</strong> produce very dense QR codes. If scanning fails,
use the PEM text or download options instead.
{% endif %}
</div>
</div>
</div>
{% endif %}
{% if rsa_key_pem %}
<div class="col-3">
<div class="fs-4 fw-bold">{{ rsa_entropy }}</div>
<small class="text-muted">bits/RSA</small>
</div>
</div>
{% endif %}
<div class="mb-4">
<h6 class="text-muted"><i class="bi bi-shield-check me-2"></i>SECURITY SUMMARY</h6>
<div class="row text-center">
<div class="col">
<div class="p-2 bg-dark rounded">
<div class="small text-muted">Phrase</div>
<div class="fs-5 text-info">{{ phrase_entropy }} bits</div>
</div>
</div>
{% if pin_entropy %}
<div class="col">
<div class="p-2 bg-dark rounded">
<div class="small text-muted">PIN</div>
<div class="fs-5 text-warning">{{ pin_entropy }} bits</div>
</div>
</div>
{% endif %}
<div class="col-3">
<div class="fs-4 fw-bold text-success">{{ total_entropy }}</div>
<small class="text-muted">bits total</small>
{% if rsa_entropy %}
<div class="col">
<div class="p-2 bg-dark rounded">
<div class="small text-muted">RSA</div>
<div class="fs-5 text-primary">{{ rsa_entropy }} bits</div>
</div>
</div>
{% endif %}
<div class="col">
<div class="p-2 bg-dark rounded">
<div class="small text-muted">Total</div>
<div class="fs-5 text-success">{{ total_entropy }} bits</div>
</div>
</div>
</div>
<small class="d-block mt-2 text-center text-muted">
+ reference photo (~80-256 bits) = <strong>{{ total_entropy + 80 }}+ bits combined</strong>
</small>
<div class="form-text text-center mt-2">
+ reference photo entropy (~80-256 bits)
</div>
</div>
<a href="/generate" class="btn btn-outline-light btn-lg w-100 mt-3">
<i class="bi bi-arrow-repeat me-2"></i>Generate New Credentials
</a>
<div class="d-grid gap-2">
<a href="{{ url_for('generate') }}" class="btn btn-outline-primary">
<i class="bi bi-arrow-repeat me-2"></i>Generate New Credentials
</a>
<a href="{{ url_for('encode_page') }}" class="btn btn-success">
<i class="bi bi-lock me-2"></i>Start Encoding
</a>
</div>
{% endif %}
</div>
</div>
{% if not generated %}
<div class="card mt-4">
<div class="card-body">
<h6 class="text-muted mb-3"><i class="bi bi-info-circle me-2"></i>About Credentials</h6>
<ul class="small text-muted mb-0">
<li class="mb-2">
<strong>Daily phrases</strong> rotate each day of the week for forward secrecy
</li>
<li class="mb-2">
<strong>PIN</strong> is static and adds another factor both parties must know
</li>
<li class="mb-2">
<strong>RSA key</strong> adds asymmetric cryptography for additional security
</li>
<li class="mb-0">
You need <strong>at least one</strong> of PIN or RSA key (or both)
</li>
</ul>
</div>
</div>
{% endif %}
</div>
</div>
<style>
.pin-container {
background: linear-gradient(145deg, #1e1e2e 0%, #2d2d44 100%);
border: 1px solid #ffc107;
border-radius: 16px;
padding: 1.5rem 2rem;
box-shadow: 0 4px 20px rgba(0, 0, 0, 0.3), 0 0 40px rgba(255, 193, 7, 0.1);
}
.pin-digits-row {
display: flex;
justify-content: center;
gap: 0.5rem;
}
.pin-digit-box {
display: inline-flex;
align-items: center;
justify-content: center;
width: 3rem;
height: 3.5rem;
background: rgba(0, 0, 0, 0.4);
border: 1px solid rgba(255, 193, 7, 0.3);
border-radius: 8px;
font-family: 'Consolas', 'Monaco', monospace;
font-size: 1.75rem;
font-weight: bold;
color: #ffc107;
text-shadow: 0 0 10px rgba(255, 193, 7, 0.5);
transition: filter 0.3s ease, transform 0.2s ease;
}
.pin-digit-box:hover {
transform: translateY(-2px);
border-color: rgba(255, 193, 7, 0.6);
}
.pin-digits-row.blurred .pin-digit-box {
filter: blur(8px);
user-select: none;
}
.pin-buttons .btn {
min-width: 80px;
}
/* Responsive */
@media (max-width: 576px) {
.pin-container {
padding: 1rem 1.25rem;
}
.pin-digit-box {
width: 2.25rem;
height: 2.75rem;
font-size: 1.25rem;
}
.pin-digits-row {
gap: 0.35rem;
}
}
</style>
{% endblock %}
{% block scripts %}
<script>
{% if not generated %}
const usePinCheckbox = document.getElementById('usePin');
const useRsaCheckbox = document.getElementById('useRsa');
// Words range slider
const wordsRange = document.getElementById('wordsRange');
const wordsValue = document.getElementById('wordsValue');
if (wordsRange) {
wordsRange.addEventListener('input', function() {
const bits = this.value * 11;
wordsValue.textContent = `${this.value} words (~${bits} bits)`;
});
}
// Toggle PIN/RSA options
const usePinCheck = document.getElementById('usePinCheck');
const useRsaCheck = document.getElementById('useRsaCheck');
const pinOptions = document.getElementById('pinOptions');
const rsaOptions = document.getElementById('rsaOptions');
const noFactorWarning = document.getElementById('noFactorWarning');
const generateBtn = document.getElementById('generateBtn');
// Toggle option visibility
usePinCheckbox.addEventListener('change', function() {
pinOptions.classList.toggle('d-none', !this.checked);
validateFactors();
updateEntropy();
});
useRsaCheckbox.addEventListener('change', function() {
rsaOptions.classList.toggle('d-none', !this.checked);
validateFactors();
updateEntropy();
});
function validateFactors() {
const hasPin = usePinCheckbox.checked;
const hasRsa = useRsaCheckbox.checked;
const valid = hasPin || hasRsa;
noFactorWarning.classList.toggle('d-none', valid);
generateBtn.disabled = !valid;
if (usePinCheck) {
usePinCheck.addEventListener('change', function() {
pinOptions.classList.toggle('d-none', !this.checked);
});
}
function updateEntropy() {
const words = parseInt(document.getElementById('wordsSelect').value);
const usePin = usePinCheckbox.checked;
const useRsa = useRsaCheckbox.checked;
const pinLen = parseInt(document.getElementById('pinSelect').value);
const phraseEntropy = words * 11;
const pinEntropy = usePin ? Math.floor(pinLen * 3.32) : 0;
const rsaEntropy = useRsa ? 128 : 0;
const total = phraseEntropy + pinEntropy + rsaEntropy;
document.getElementById('entropyDisplay').textContent = '~' + total + ' bits';
// Update progress bar
const pct = Math.min(100, Math.max(10, (total - 30) * 0.5));
document.getElementById('entropyBar').style.width = pct + '%';
// Update description
let desc;
if (total < 50) desc = 'Basic security';
else if (total < 80) desc = 'Good for most use cases';
else if (total < 120) desc = 'Strong security';
else if (total < 180) desc = 'Very strong security';
else desc = 'Maximum security';
document.getElementById('entropyDesc').textContent = desc;
if (useRsaCheck) {
useRsaCheck.addEventListener('change', function() {
rsaOptions.classList.toggle('d-none', !this.checked);
});
}
document.getElementById('wordsSelect').addEventListener('change', updateEntropy);
document.getElementById('pinSelect').addEventListener('change', updateEntropy);
document.getElementById('rsaSelect').addEventListener('change', updateEntropy);
// Form submit
document.getElementById('generateForm').addEventListener('submit', function(e) {
if (!usePinCheckbox.checked && !useRsaCheckbox.checked) {
e.preventDefault();
noFactorWarning.classList.remove('d-none');
return;
// PIN visibility toggle
let pinHidden = false;
function togglePinVisibility() {
const pinDigits = document.getElementById('pinDigits');
const icon = document.getElementById('pinToggleIcon');
const text = document.getElementById('pinToggleText');
pinHidden = !pinHidden;
if (pinHidden) {
pinDigits.classList.add('blurred');
icon.className = 'bi bi-eye';
text.textContent = 'Show';
} else {
pinDigits.classList.remove('blurred');
icon.className = 'bi bi-eye-slash';
text.textContent = 'Hide';
}
}
// Copy PIN
function copyPin() {
const pin = '{{ pin|default("", true) }}';
const icon = document.getElementById('pinCopyIcon');
const text = document.getElementById('pinCopyText');
generateBtn.disabled = true;
generateBtn.innerHTML = '<span class="spinner-border spinner-border-sm me-2"></span>Generating...';
});
// Initial state
validateFactors();
updateEntropy();
{% else %}
// Copy RSA key to clipboard
document.getElementById('copyKeyBtn')?.addEventListener('click', function() {
const keyText = document.getElementById('rsaKeyText');
navigator.clipboard.writeText(keyText.value).then(() => {
this.innerHTML = '<i class="bi bi-check me-1"></i> Copied!';
navigator.clipboard.writeText(pin).then(() => {
icon.className = 'bi bi-check';
text.textContent = 'Copied!';
setTimeout(() => {
this.innerHTML = '<i class="bi bi-clipboard me-1"></i> Copy to Clipboard';
icon.className = 'bi bi-clipboard';
text.textContent = 'Copy';
}, 2000);
});
});
}
{% endif %}
// Toggle all phrases visibility
let phrasesHidden = false;
function toggleAllPhrases() {
phrasesHidden = !phrasesHidden;
document.querySelectorAll('.phrase-display').forEach(el => {
el.style.filter = phrasesHidden ? 'blur(8px)' : 'none';
});
}
// Print QR code
function printQrCode() {
const qrImg = document.getElementById('qrCodeImage');
if (!qrImg) return;
const printWindow = window.open('', '_blank');
printWindow.document.write(`
<!DOCTYPE html>
<html>
<head>
<title>Stegasoo RSA Key QR Code</title>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
min-height: 100vh;
margin: 0;
font-family: sans-serif;
}
img { max-width: 400px; }
.warning {
margin-top: 20px;
padding: 10px;
border: 2px solid #ff9800;
background: #fff3e0;
max-width: 400px;
text-align: center;
font-size: 12px;
}
</style>
</head>
<body>
<h2>Stegasoo RSA Private Key</h2>
<img src="${qrImg.src}" alt="RSA Key QR Code">
<div class="warning">
<strong>⚠️ SECURITY WARNING</strong><br>
This QR code contains your unencrypted RSA private key.<br>
Store securely and destroy after use.
</div>
<script>window.onload = function() { window.print(); }<\/script>
</body>
</html>
`);
printWindow.document.close();
}
</script>
{% endblock %}

View File

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

View File

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

View File

@@ -1,16 +1,36 @@
# Core dependencies
pillow>=10.0.0
# Stegasoo Requirements
# ====================
# Core Dependencies
cryptography>=41.0.0
argon2-cffi>=23.0.0
Pillow>=10.0.0
# CLI (optional)
click>=8.0.0
# Key Derivation (recommended for stronger security)
argon2-cffi>=23.1.0
# Web UI (optional)
flask>=3.0.0
gunicorn>=21.0.0
# QR Code Generation & Reading
qrcode>=7.4.0
pyzbar>=0.1.9
# REST API (optional)
# fastapi>=0.100.0
# uvicorn[standard]>=0.20.0
# python-multipart>=0.0.6
# Web Frontend (Flask)
Flask>=3.0.0
# API Frontend (FastAPI)
fastapi>=0.109.0
uvicorn[standard]>=0.27.0
python-multipart>=0.0.6
# CLI Frontend
click>=8.1.0
# Development & Testing
pytest>=7.4.0
pytest-cov>=4.1.0
# Optional: Better performance for Pillow
# pillow-simd>=9.0.0 # Uncomment if available for your platform
# Note: pyzbar requires system library:
# Ubuntu/Debian: sudo apt-get install libzbar0
# macOS: brew install zbar
# Windows: Included in pyzbar wheel

View File

@@ -1,10 +1,10 @@
"""
Stegasoo - Secure Steganography Library
A Python library for hiding encrypted messages in images using
A Python library for hiding encrypted messages and files in images using
hybrid photo + passphrase + PIN authentication.
Basic Usage:
Basic Usage - Text Message:
from stegasoo import encode, decode, generate_credentials
# Generate credentials
@@ -30,16 +30,41 @@ Basic Usage:
f.write(result.stego_image)
# Decode a message
message = decode(
decoded = decode(
stego_image=result.stego_image,
reference_photo=ref_photo,
day_phrase="apple forest thunder",
pin="123456"
)
print(message) # "Meet at midnight"
print(decoded.message) # "Meet at midnight"
File Embedding:
from stegasoo import encode_file, decode, FilePayload
# Encode a file
result = encode_file(
filepath="secret_document.pdf",
reference_photo=ref_photo,
carrier_image=carrier,
day_phrase="apple forest thunder",
pin="123456"
)
# Decode - automatically detects file vs text
decoded = decode(...)
if decoded.is_file:
with open(decoded.filename, 'wb') as f:
f.write(decoded.file_data)
else:
print(decoded.message)
Debugging:
from stegasoo.debug import debug
debug.enable(True) # Enable debug output
debug.enable_performance(True) # Enable timing
"""
from .constants import __version__, DAY_NAMES
from .constants import __version__, DAY_NAMES, MAX_MESSAGE_SIZE, MAX_FILE_PAYLOAD_SIZE
from .models import (
Credentials,
EncodeInput,
@@ -49,6 +74,7 @@ from .models import (
EmbedStats,
KeyInfo,
ValidationResult,
FilePayload,
)
from .exceptions import (
StegasooError,
@@ -83,6 +109,8 @@ from .keygen import (
from .validation import (
validate_pin,
validate_message,
validate_payload,
validate_file_payload,
validate_image,
validate_rsa_key,
validate_security_factors,
@@ -90,6 +118,7 @@ from .validation import (
validate_date_string,
require_valid_pin,
require_valid_message,
require_valid_payload,
require_valid_image,
require_valid_rsa_key,
require_security_factors,
@@ -97,6 +126,7 @@ from .validation import (
from .crypto import (
encrypt_message,
decrypt_message,
decrypt_message_text,
derive_hybrid_key,
derive_pixel_key,
hash_photo,
@@ -109,6 +139,9 @@ from .steganography import (
extract_from_image,
calculate_capacity,
get_image_dimensions,
get_image_format,
is_lossless_format,
LOSSLESS_FORMATS,
)
from .utils import (
generate_filename,
@@ -120,13 +153,38 @@ from .utils import (
SecureDeleter,
format_file_size,
)
from .debug import debug # Import debug utilities
# QR Code utilities (optional, depends on qrcode and pyzbar)
try:
from .qr_utils import (
generate_qr_code,
read_qr_code,
read_qr_code_from_file,
extract_key_from_qr,
extract_key_from_qr_file,
compress_data,
decompress_data,
auto_decompress,
normalize_pem,
is_compressed,
can_fit_in_qr,
needs_compression,
has_qr_read,
has_qr_write,
has_qr_support,
)
HAS_QR_UTILS = True
except ImportError:
HAS_QR_UTILS = False
from datetime import date
from typing import Optional
from pathlib import Path
from typing import Optional, Union, Dict, Any
def encode(
message: str,
message: Union[str, bytes, FilePayload],
reference_photo: bytes,
carrier_image: bytes,
day_phrase: str,
@@ -134,15 +192,16 @@ def encode(
rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None,
date_str: Optional[str] = None,
output_format: Optional[str] = None,
) -> EncodeResult:
"""
Encode a secret message into an image.
Encode a secret message or file into an image.
High-level convenience function that handles validation,
encryption, and embedding in one call.
Args:
message: Secret message to hide
message: Secret message (str), raw bytes, or FilePayload to hide
reference_photo: Shared reference photo bytes
carrier_image: Image to hide message in
day_phrase: Today's passphrase
@@ -150,6 +209,8 @@ def encode(
rsa_key_data: RSA private key PEM bytes (optional if using PIN)
rsa_password: Password for RSA key if encrypted
date_str: Date string YYYY-MM-DD (defaults to today)
output_format: Force output format ('PNG', 'BMP'). If None, preserves
carrier format for lossless types, defaults to PNG for lossy.
Returns:
EncodeResult with stego image and metadata
@@ -159,9 +220,17 @@ def encode(
SecurityFactorError: If no PIN or RSA key provided
CapacityError: If carrier is too small
EncryptionError: If encryption fails
Note:
Output format is always lossless (PNG or BMP) to preserve hidden data.
If carrier is JPEG/GIF, output will be PNG to maintain data integrity.
"""
# Debug logging
debug.print(f"encode called: message type={type(message).__name__}, "
f"day_phrase='{day_phrase[:20]}...', pin_length={len(pin)}")
# Validate inputs
require_valid_message(message)
require_valid_payload(message)
require_valid_image(carrier_image, "Carrier image")
require_security_factors(pin, rsa_key_data)
@@ -174,21 +243,34 @@ def encode(
if date_str is None:
date_str = date.today().isoformat()
# Encrypt message
debug.print(f"Encoding for date: {date_str}")
# Encrypt message/file
encrypted = encrypt_message(
message, reference_photo, day_phrase, date_str, pin, rsa_key_data
)
# Debug: show encrypted data size
debug.print(f"Encrypted payload: {len(encrypted)} bytes")
# Get pixel key
pixel_key = derive_pixel_key(
reference_photo, day_phrase, date_str, pin, rsa_key_data
)
# Embed in image
stego_data, stats = embed_in_image(carrier_image, encrypted, pixel_key)
debug.data(pixel_key, "Pixel key")
# Generate filename
filename = generate_filename(date_str)
# Embed in image (returns extension too)
stego_data, stats, extension = embed_in_image(
carrier_image, encrypted, pixel_key, output_format=output_format
)
# Generate filename with correct extension
filename = generate_filename(date_str, extension=extension)
debug.print(f"Encoding complete: {filename}, "
f"modified {stats.pixels_modified}/{stats.total_pixels} pixels "
f"({stats.modification_percent:.2f}%)")
return EncodeResult(
stego_image=stego_data,
@@ -200,6 +282,105 @@ 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
"""
debug.print(f"encode_file called: filepath={filepath}")
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
"""
debug.print(f"encode_bytes called: filename={filename}, data_size={len(data)}")
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,
)
@debug.time
def decode(
stego_image: bytes,
reference_photo: bytes,
@@ -207,15 +388,15 @@ def decode(
pin: str = "",
rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None,
) -> str:
) -> DecodeResult:
"""
Decode a secret message from a stego image.
Decode a secret message or file from a stego image.
High-level convenience function that handles extraction
and decryption in one call.
Args:
stego_image: Image containing hidden message
stego_image: Image containing hidden message/file
reference_photo: Shared reference photo bytes
day_phrase: Passphrase for the day message was encoded
pin: Static PIN (if used during encoding)
@@ -223,7 +404,12 @@ def decode(
rsa_password: Password for RSA key if encrypted
Returns:
Decrypted message string
DecodeResult with:
- .payload_type: 'text' or 'file'
- .message: Decoded text (if text)
- .file_data: Decoded bytes (if file)
- .filename: Original filename (if file)
- .is_text / .is_file: Convenience properties
Raises:
ValidationError: If inputs are invalid
@@ -231,6 +417,9 @@ def decode(
ExtractionError: If data cannot be extracted
DecryptionError: If decryption fails
"""
debug.print(f"decode called: stego_image_size={len(stego_image)}, "
f"day_phrase='{day_phrase[:20]}...'")
# Validate inputs
require_security_factors(pin, rsa_key_data)
@@ -245,12 +434,15 @@ def decode(
reference_photo, day_phrase, date_str, pin, rsa_key_data
)
debug.data(pixel_key, "Pixel key for extraction")
encrypted = extract_from_image(stego_image, pixel_key)
# If we got data, check if it's from a different date
if encrypted:
header = parse_header(encrypted)
if header and header['date'] != date_str:
debug.print(f"Found different date in header: {header['date']} (expected {date_str})")
# Re-extract with correct date
pixel_key = derive_pixel_key(
reference_photo, day_phrase, header['date'], pin, rsa_key_data
@@ -258,23 +450,81 @@ def decode(
encrypted = extract_from_image(stego_image, pixel_key)
if not encrypted:
debug.print("No data extracted from image")
raise ExtractionError("Could not extract data. Check your inputs.")
# Decrypt
debug.print(f"Extracted {len(encrypted)} bytes from image")
debug.data(encrypted[:64], "First 64 bytes of extracted data")
# Decrypt and return full result
return decrypt_message(encrypted, reference_photo, day_phrase, pin, rsa_key_data)
def decode_text(
stego_image: bytes,
reference_photo: bytes,
day_phrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None,
) -> str:
"""
Decode a text message from a stego image.
Convenience function that returns just the text string.
Raises an error if the content is a binary file.
Args:
stego_image: Image containing hidden message
reference_photo: Shared reference photo bytes
day_phrase: Passphrase for the day message was encoded
pin: Static PIN (if used during encoding)
rsa_key_data: RSA private key PEM bytes (if used during encoding)
rsa_password: Password for RSA key if encrypted
Returns:
Decrypted message string
Raises:
DecryptionError: If content is a binary file, not text
"""
debug.print("decode_text called")
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:
debug.print(f"File is binary: {result.filename or 'unnamed'}")
raise DecryptionError(
f"Content is a binary file ({result.filename or 'unnamed'}), not text. "
"Use decode() instead and check result.is_file."
)
return ""
debug.print(f"Decoded text: {result.message[:100] if result.message else 'empty'}...")
return result.message or ""
__all__ = [
# Version
'__version__',
# High-level API
'encode',
'encode_file',
'encode_bytes',
'decode',
'decode_text',
'generate_credentials',
# Constants
'DAY_NAMES',
'LOSSLESS_FORMATS',
'MAX_MESSAGE_SIZE',
'MAX_FILE_PAYLOAD_SIZE',
# Models
'Credentials',
@@ -285,6 +535,7 @@ __all__ = [
'EmbedStats',
'KeyInfo',
'ValidationResult',
'FilePayload',
# Exceptions
'StegasooError',
@@ -318,6 +569,8 @@ __all__ = [
# Validation
'validate_pin',
'validate_message',
'validate_payload',
'validate_file_payload',
'validate_image',
'validate_rsa_key',
'validate_security_factors',
@@ -325,6 +578,7 @@ __all__ = [
'validate_date_string',
'require_valid_pin',
'require_valid_message',
'require_valid_payload',
'require_valid_image',
'require_valid_rsa_key',
'require_security_factors',
@@ -332,6 +586,7 @@ __all__ = [
# Crypto
'encrypt_message',
'decrypt_message',
'decrypt_message_text',
'derive_hybrid_key',
'derive_pixel_key',
'hash_photo',
@@ -344,6 +599,8 @@ __all__ = [
'extract_from_image',
'calculate_capacity',
'get_image_dimensions',
'get_image_format',
'is_lossless_format',
# Utilities
'generate_filename',
@@ -354,4 +611,7 @@ __all__ = [
'secure_delete',
'SecureDeleter',
'format_file_size',
]
# Debugging
'debug',
]

View File

@@ -11,7 +11,7 @@ from pathlib import Path
# VERSION
# ============================================================================
__version__ = "2.0.1"
__version__ = "2.1.3"
# ============================================================================
# FILE FORMAT
@@ -20,6 +20,10 @@ __version__ = "2.0.1"
MAGIC_HEADER = b'\x89ST3'
FORMAT_VERSION = 3
# Payload type markers
PAYLOAD_TEXT = 0x01
PAYLOAD_FILE = 0x02
# ============================================================================
# CRYPTO PARAMETERS
# ============================================================================
@@ -40,9 +44,13 @@ PBKDF2_ITERATIONS = 600000
# INPUT LIMITS
# ============================================================================
MAX_IMAGE_PIXELS = 4_000_000 # ~4 megapixels (2000x2000)
MAX_MESSAGE_SIZE = 50_000 # 50 KB
MAX_FILE_SIZE = 5 * 1024 * 1024 # 5 MB
MAX_IMAGE_PIXELS = 24_000_000 # ~24 megapixels
MAX_MESSAGE_SIZE = 250_000 # 250 KB (text messages)
MAX_FILENAME_LENGTH = 255 # Max filename length to store
# Example in constants.py
MAX_FILE_SIZE = 30 * 1024 * 1024 # 30MB total file size
MAX_FILE_PAYLOAD_SIZE = 2 * 1024 * 1024 # 2MB payload
MIN_PIN_LENGTH = 6
MAX_PIN_LENGTH = 9
@@ -78,11 +86,17 @@ DAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday',
def get_data_dir() -> Path:
"""Get the data directory path."""
# Check multiple locations
# From src/stegasoo/constants.py:
# .parent = src/stegasoo/
# .parent.parent = src/
# .parent.parent.parent = project root (where data/ lives)
candidates = [
Path(__file__).parent.parent.parent.parent / 'data', # Development
Path(__file__).parent.parent.parent / 'data', # Development: src/stegasoo -> project root
Path(__file__).parent / 'data', # Installed package
Path('/app/data'), # Docker
Path.cwd() / 'data', # Current directory
Path.cwd().parent / 'data', # One level up from cwd
Path.cwd().parent.parent / 'data', # Two levels up from cwd
]
for path in candidates:

View File

@@ -2,13 +2,15 @@
Stegasoo Cryptographic Functions
Key derivation, encryption, and decryption using AES-256-GCM.
Supports both text messages and binary file payloads.
"""
import io
import hashlib
import secrets
import struct
from typing import Optional
import json
from typing import Optional, Union
from PIL import Image
from cryptography.hazmat.primitives.ciphers import Cipher, algorithms, modes
@@ -19,7 +21,10 @@ from .constants import (
SALT_SIZE, IV_SIZE, TAG_SIZE,
ARGON2_TIME_COST, ARGON2_MEMORY_COST, ARGON2_PARALLELISM,
PBKDF2_ITERATIONS,
PAYLOAD_TEXT, PAYLOAD_FILE,
MAX_FILENAME_LENGTH,
)
from .models import FilePayload, DecodeResult
from .exceptions import (
EncryptionError, DecryptionError, KeyDerivationError, InvalidHeaderError
)
@@ -171,8 +176,112 @@ def derive_pixel_key(
return hashlib.sha256(material + b"pixel_selection").digest()
def _pack_payload(
content: Union[str, bytes, FilePayload],
) -> tuple[bytes, int]:
"""
Pack payload with type marker and metadata.
Format for text:
[type:1][data]
Format for file:
[type:1][filename_len:2][filename][mime_len:2][mime][data]
Args:
content: Text string, raw bytes, or FilePayload
Returns:
Tuple of (packed bytes, payload type)
"""
if isinstance(content, str):
# Text message
data = content.encode('utf-8')
return bytes([PAYLOAD_TEXT]) + data, PAYLOAD_TEXT
elif isinstance(content, FilePayload):
# File with metadata
filename = content.filename[:MAX_FILENAME_LENGTH].encode('utf-8')
mime = (content.mime_type or '')[:100].encode('utf-8')
packed = (
bytes([PAYLOAD_FILE]) +
struct.pack('>H', len(filename)) +
filename +
struct.pack('>H', len(mime)) +
mime +
content.data
)
return packed, PAYLOAD_FILE
else:
# Raw bytes - treat as file with no name
packed = (
bytes([PAYLOAD_FILE]) +
struct.pack('>H', 0) + # No filename
struct.pack('>H', 0) + # No mime
content
)
return packed, PAYLOAD_FILE
def _unpack_payload(data: bytes) -> DecodeResult:
"""
Unpack payload and extract content with metadata.
Args:
data: Packed payload bytes
Returns:
DecodeResult with appropriate content
"""
if len(data) < 1:
raise DecryptionError("Empty payload")
payload_type = data[0]
if payload_type == PAYLOAD_TEXT:
# Text message
text = data[1:].decode('utf-8')
return DecodeResult(payload_type='text', message=text)
elif payload_type == PAYLOAD_FILE:
# File with metadata
offset = 1
# Read filename
filename_len = struct.unpack('>H', data[offset:offset+2])[0]
offset += 2
filename = data[offset:offset+filename_len].decode('utf-8') if filename_len else None
offset += filename_len
# Read mime type
mime_len = struct.unpack('>H', data[offset:offset+2])[0]
offset += 2
mime_type = data[offset:offset+mime_len].decode('utf-8') if mime_len else None
offset += mime_len
# Rest is file data
file_data = data[offset:]
return DecodeResult(
payload_type='file',
file_data=file_data,
filename=filename,
mime_type=mime_type
)
else:
# Unknown type - try to decode as text (backward compatibility)
try:
text = data.decode('utf-8')
return DecodeResult(payload_type='text', message=text)
except UnicodeDecodeError:
return DecodeResult(payload_type='file', file_data=data)
def encrypt_message(
message: str | bytes,
message: Union[str, bytes, FilePayload],
photo_data: bytes,
day_phrase: str,
date_str: str,
@@ -180,7 +289,7 @@ def encrypt_message(
rsa_key_data: Optional[bytes] = None
) -> bytes:
"""
Encrypt message using AES-256-GCM with hybrid key derivation.
Encrypt message or file using AES-256-GCM with hybrid key derivation.
Message format:
- Magic header (4 bytes)
@@ -193,7 +302,7 @@ def encrypt_message(
- Ciphertext (variable, padded)
Args:
message: Message to encrypt
message: Message string, raw bytes, or FilePayload to encrypt
photo_data: Reference photo bytes
day_phrase: The day's phrase
date_str: Date string (YYYY-MM-DD)
@@ -211,15 +320,15 @@ def encrypt_message(
key = derive_hybrid_key(photo_data, day_phrase, date_str, salt, pin, rsa_key_data)
iv = secrets.token_bytes(IV_SIZE)
if isinstance(message, str):
message = message.encode('utf-8')
# Pack payload with type marker
packed_payload, _ = _pack_payload(message)
# Random padding to hide message length
padding_len = secrets.randbelow(256) + 64
padded_len = ((len(message) + padding_len + 255) // 256) * 256
padding_needed = padded_len - len(message)
padding = secrets.token_bytes(padding_needed - 4) + struct.pack('>I', len(message))
padded_message = message + padding
padded_len = ((len(packed_payload) + padding_len + 255) // 256) * 256
padding_needed = padded_len - len(packed_payload)
padding = secrets.token_bytes(padding_needed - 4) + struct.pack('>I', len(packed_payload))
padded_message = packed_payload + padding
# Encrypt with AES-256-GCM
cipher = Cipher(algorithms.AES(key), modes.GCM(iv), backend=default_backend())
@@ -291,7 +400,7 @@ def decrypt_message(
day_phrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None
) -> str:
) -> DecodeResult:
"""
Decrypt message using the embedded date from the header.
@@ -303,7 +412,7 @@ def decrypt_message(
rsa_key_data: Optional RSA key bytes
Returns:
Decrypted message string
DecodeResult with decrypted content
Raises:
InvalidHeaderError: If data doesn't have valid Stegasoo header
@@ -329,7 +438,11 @@ def decrypt_message(
padded_plaintext = decryptor.update(header['ciphertext']) + decryptor.finalize()
original_length = struct.unpack('>I', padded_plaintext[-4:])[0]
return padded_plaintext[:original_length].decode('utf-8')
payload_data = padded_plaintext[:original_length]
result = _unpack_payload(payload_data)
result.date_encoded = header['date']
return result
except Exception as e:
raise DecryptionError(
@@ -337,6 +450,47 @@ def decrypt_message(
) from e
def decrypt_message_text(
encrypted_data: bytes,
photo_data: bytes,
day_phrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None
) -> str:
"""
Decrypt message and return as text string.
For backward compatibility - returns text content or raises error for files.
Args:
encrypted_data: Encrypted message bytes
photo_data: Reference photo bytes
day_phrase: The day's phrase
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
Returns:
Decrypted message string
Raises:
DecryptionError: If decryption fails or content is a file
"""
result = decrypt_message(encrypted_data, photo_data, day_phrase, pin, rsa_key_data)
if result.is_file:
if result.file_data:
# Try to decode as text
try:
return result.file_data.decode('utf-8')
except UnicodeDecodeError:
raise DecryptionError(
f"Content is a binary file ({result.filename or 'unnamed'}), not text"
)
return ""
return result.message or ""
def get_date_from_encrypted(encrypted_data: bytes) -> Optional[str]:
"""
Extract the date string from encrypted data without decrypting.

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

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

View File

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

View File

@@ -6,7 +6,7 @@ Dataclasses for structured data exchange between modules and frontends.
from dataclasses import dataclass, field
from datetime import date
from typing import Optional
from typing import Optional, Union
@dataclass
@@ -43,10 +43,35 @@ class Credentials:
return self.phrase_entropy + self.pin_entropy + self.rsa_entropy
@dataclass
class FilePayload:
"""Represents a file to be embedded."""
data: bytes
filename: str
mime_type: Optional[str] = None
@property
def size(self) -> int:
return len(self.data)
@classmethod
def from_file(cls, filepath: str, filename: Optional[str] = None) -> 'FilePayload':
"""Create FilePayload from a file path."""
from pathlib import Path
import mimetypes
path = Path(filepath)
data = path.read_bytes()
name = filename or path.name
mime, _ = mimetypes.guess_type(name)
return cls(data=data, filename=name, mime_type=mime)
@dataclass
class EncodeInput:
"""Input parameters for encoding a message."""
message: str
message: Union[str, bytes, FilePayload] # Text, raw bytes, or file
reference_photo: bytes
carrier_image: bytes
day_phrase: str
@@ -90,8 +115,26 @@ class DecodeInput:
@dataclass
class DecodeResult:
"""Result of decoding operation."""
message: str
date_encoded: str
payload_type: str # 'text' or 'file'
message: Optional[str] = None # For text payloads
file_data: Optional[bytes] = None # For file payloads
filename: Optional[str] = None # Original filename for file payloads
mime_type: Optional[str] = None # MIME type hint
date_encoded: Optional[str] = None
@property
def is_file(self) -> bool:
return self.payload_type == 'file'
@property
def is_text(self) -> bool:
return self.payload_type == 'text'
def get_content(self) -> Union[str, bytes]:
"""Get the decoded content (text or bytes)."""
if self.is_text:
return self.message or ""
return self.file_data or b""
@dataclass

397
src/stegasoo/qr_utils.py Normal file
View File

@@ -0,0 +1,397 @@
"""
Stegasoo QR Code Utilities
Functions for generating and reading QR codes containing RSA keys.
Supports automatic compression for large keys.
IMPROVEMENTS IN THIS VERSION:
- Much more robust PEM normalization
- Better handling of QR code extraction edge cases
- Improved error messages
"""
import io
import zlib
import base64
from typing import Optional, Tuple
from PIL import Image
# QR code generation
try:
import qrcode
from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_M
HAS_QRCODE_WRITE = True
except ImportError:
HAS_QRCODE_WRITE = False
# QR code reading
try:
from pyzbar.pyzbar import decode as pyzbar_decode
from pyzbar.pyzbar import ZBarSymbol
HAS_QRCODE_READ = True
except ImportError:
HAS_QRCODE_READ = False
# Constants
COMPRESSION_PREFIX = "STEGASOO-Z:"
QR_MAX_BINARY = 2900 # Safe limit for binary data in QR
def compress_data(data: str) -> str:
"""
Compress string data for QR code storage.
Args:
data: String to compress
Returns:
Compressed string with STEGASOO-Z: prefix
"""
compressed = zlib.compress(data.encode('utf-8'), level=9)
encoded = base64.b64encode(compressed).decode('ascii')
return COMPRESSION_PREFIX + encoded
def decompress_data(data: str) -> str:
"""
Decompress data from QR code.
Args:
data: Compressed string with STEGASOO-Z: prefix
Returns:
Original uncompressed string
Raises:
ValueError: If data is not valid compressed format
"""
if not data.startswith(COMPRESSION_PREFIX):
raise ValueError("Data is not in compressed format")
encoded = data[len(COMPRESSION_PREFIX):]
compressed = base64.b64decode(encoded)
return zlib.decompress(compressed).decode('utf-8')
def normalize_pem(pem_data: str) -> str:
"""
Normalize PEM data to ensure proper formatting for cryptography library.
The cryptography library is very particular about PEM formatting.
This function handles all common issues from QR code extraction:
- Inconsistent line endings (CRLF, LF, CR)
- Missing newlines after header/before footer
- Extra whitespace, tabs, multiple spaces
- Non-ASCII characters
- Incorrect base64 padding
- Malformed headers/footers
Args:
pem_data: Raw PEM string from QR code
Returns:
Properly formatted PEM string that cryptography library will accept
"""
import re
# Step 1: Normalize ALL line endings to \n
pem_data = pem_data.replace('\r\n', '\n').replace('\r', '\n')
# Step 2: Remove leading/trailing whitespace
pem_data = pem_data.strip()
# Step 3: Remove any non-ASCII characters (QR artifacts)
pem_data = ''.join(char for char in pem_data if ord(char) < 128)
# Step 4: Extract header, content, and footer with flexible regex
# This handles variations like:
# - "PRIVATE KEY" vs "RSA PRIVATE KEY"
# - Extra spaces in headers
# - Missing spaces
pattern = r'(-----BEGIN[^-]*-----)(.*?)(-----END[^-]*-----)'
match = re.search(pattern, pem_data, re.DOTALL | re.IGNORECASE)
if not match:
# Fallback: try even more permissive pattern
pattern = r'(-+BEGIN[^-]+-+)(.*?)(-+END[^-]+-+)'
match = re.search(pattern, pem_data, re.DOTALL | re.IGNORECASE)
if not match:
# Last resort: return original if can't parse
return pem_data
header_raw = match.group(1).strip()
content_raw = match.group(2)
footer_raw = match.group(3).strip()
# Step 5: Normalize header and footer
# Standardize spacing and ensure proper format
header = re.sub(r'\s+', ' ', header_raw)
footer = re.sub(r'\s+', ' ', footer_raw)
# Ensure exactly 5 dashes on each side
header = re.sub(r'^-+', '-----', header)
header = re.sub(r'-+$', '-----', header)
footer = re.sub(r'^-+', '-----', footer)
footer = re.sub(r'-+$', '-----', footer)
# Step 6: Clean the base64 content THOROUGHLY
# Remove ALL whitespace: spaces, tabs, newlines
# Keep only valid base64 characters: A-Z, a-z, 0-9, +, /, =
content_clean = ''.join(
char for char in content_raw
if char.isalnum() or char in '+/='
)
# Double-check: remove any remaining invalid characters
content_clean = re.sub(r'[^A-Za-z0-9+/=]', '', content_clean)
# Step 7: Fix base64 padding
# Base64 strings must be divisible by 4
remainder = len(content_clean) % 4
if remainder:
content_clean += '=' * (4 - remainder)
# Step 8: Split into 64-character lines (PEM standard)
lines = [content_clean[i:i+64] for i in range(0, len(content_clean), 64)]
# Step 9: Reconstruct with EXACT PEM formatting
# Format: header\ncontent_line1\ncontent_line2\n...\nfooter\n
return header + '\n' + '\n'.join(lines) + '\n' + footer + '\n'
def is_compressed(data: str) -> bool:
"""Check if data has compression prefix."""
return data.startswith(COMPRESSION_PREFIX)
def auto_decompress(data: str) -> str:
"""
Automatically decompress data if compressed, otherwise return as-is.
Args:
data: Possibly compressed string
Returns:
Decompressed string
"""
if is_compressed(data):
return decompress_data(data)
return data
def get_compressed_size(data: str) -> int:
"""Get size of data after compression (including prefix)."""
return len(compress_data(data))
def can_fit_in_qr(data: str, compress: bool = False) -> bool:
"""
Check if data can fit in a QR code.
Args:
data: String data
compress: Whether compression will be used
Returns:
True if data fits
"""
if compress:
size = get_compressed_size(data)
else:
size = len(data.encode('utf-8'))
return size <= QR_MAX_BINARY
def needs_compression(data: str) -> bool:
"""Check if data needs compression to fit in QR code."""
return not can_fit_in_qr(data, compress=False) and can_fit_in_qr(data, compress=True)
def generate_qr_code(
data: str,
compress: bool = False,
error_correction=None
) -> bytes:
"""
Generate a QR code PNG from string data.
Args:
data: String data to encode
compress: Whether to compress data first
error_correction: QR error correction level (default: auto)
Returns:
PNG image bytes
Raises:
RuntimeError: If qrcode library not available
ValueError: If data too large for QR code
"""
if not HAS_QRCODE_WRITE:
raise RuntimeError("qrcode library not installed. Run: pip install qrcode[pil]")
qr_data = data
# Compress if requested
if compress:
qr_data = compress_data(data)
# Check size
if len(qr_data.encode('utf-8')) > QR_MAX_BINARY:
raise ValueError(
f"Data too large for QR code ({len(qr_data)} bytes). "
f"Maximum: {QR_MAX_BINARY} bytes"
)
# Use lower error correction for larger data
if error_correction is None:
error_correction = ERROR_CORRECT_L if len(qr_data) > 1000 else ERROR_CORRECT_M
qr = qrcode.QRCode(
version=None,
error_correction=error_correction,
box_size=10,
border=4,
)
qr.add_data(qr_data)
qr.make(fit=True)
img = qr.make_image(fill_color="black", back_color="white")
buf = io.BytesIO()
img.save(buf, format='PNG')
buf.seek(0)
return buf.getvalue()
def read_qr_code(image_data: bytes) -> Optional[str]:
"""
Read QR code from image data.
Args:
image_data: Image bytes (PNG, JPG, etc.)
Returns:
Decoded string, or None if no QR code found
Raises:
RuntimeError: If pyzbar library not available
"""
if not HAS_QRCODE_READ:
raise RuntimeError(
"pyzbar library not installed. Run: pip install pyzbar\n"
"Also requires system library: sudo apt-get install libzbar0"
)
try:
img = Image.open(io.BytesIO(image_data))
# Convert to RGB if necessary (pyzbar works best with RGB/grayscale)
if img.mode not in ('RGB', 'L'):
img = img.convert('RGB')
# Decode QR codes
decoded = pyzbar_decode(img, symbols=[ZBarSymbol.QRCODE])
if not decoded:
return None
# Return first QR code found
return decoded[0].data.decode('utf-8')
except Exception:
return None
def read_qr_code_from_file(filepath: str) -> Optional[str]:
"""
Read QR code from image file.
Args:
filepath: Path to image file
Returns:
Decoded string, or None if no QR code found
"""
with open(filepath, 'rb') as f:
return read_qr_code(f.read())
def extract_key_from_qr(image_data: bytes) -> Optional[str]:
"""
Extract RSA key from QR code image, auto-decompressing if needed.
This function is more robust than the original, with better error handling
and PEM normalization.
Args:
image_data: Image bytes containing QR code
Returns:
PEM-encoded RSA key string, or None if not found/invalid
"""
# Step 1: Read QR code
qr_data = read_qr_code(image_data)
if not qr_data:
return None
# Step 2: Auto-decompress if needed
try:
if is_compressed(qr_data):
key_pem = decompress_data(qr_data)
else:
key_pem = qr_data
except Exception as e:
# If decompression fails, try using data as-is
key_pem = qr_data
# Step 3: Validate it looks like a PEM key
if '-----BEGIN' not in key_pem or '-----END' not in key_pem:
return None
# Step 4: Aggressively normalize PEM format
# This is crucial - QR codes can introduce subtle formatting issues
try:
key_pem = normalize_pem(key_pem)
except Exception as e:
# If normalization fails, return None rather than broken PEM
return None
# Step 5: Final validation - ensure it still looks like PEM
if '-----BEGIN' in key_pem and '-----END' in key_pem:
return key_pem
return None
def extract_key_from_qr_file(filepath: str) -> Optional[str]:
"""
Extract RSA key from QR code image file.
Args:
filepath: Path to image file containing QR code
Returns:
PEM-encoded RSA key string, or None if not found/invalid
"""
with open(filepath, 'rb') as f:
return extract_key_from_qr(f.read())
def has_qr_write() -> bool:
"""Check if QR code writing is available."""
return HAS_QRCODE_WRITE
def has_qr_read() -> bool:
"""Check if QR code reading is available."""
return HAS_QRCODE_READ
def has_qr_support() -> bool:
"""Check if full QR code support is available."""
return HAS_QRCODE_WRITE and HAS_QRCODE_READ

View File

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

View File

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

View File

@@ -5,17 +5,17 @@ Validators for all user inputs with clear error messages.
"""
import io
from typing import Optional
from typing import Optional, Union
from PIL import Image
from .constants import (
MIN_PIN_LENGTH, MAX_PIN_LENGTH,
MAX_MESSAGE_SIZE, MAX_IMAGE_PIXELS, MAX_FILE_SIZE,
MAX_MESSAGE_SIZE, MAX_FILE_PAYLOAD_SIZE, MAX_IMAGE_PIXELS, MAX_FILE_SIZE,
MIN_RSA_BITS, MIN_KEY_PASSWORD_LENGTH,
ALLOWED_IMAGE_EXTENSIONS, ALLOWED_KEY_EXTENSIONS,
)
from .models import ValidationResult
from .models import ValidationResult, FilePayload
from .exceptions import (
ValidationError, PinValidationError, MessageValidationError,
ImageValidationError, KeyValidationError, SecurityFactorError,
@@ -61,7 +61,7 @@ def validate_pin(pin: str, required: bool = False) -> ValidationResult:
def validate_message(message: str) -> ValidationResult:
"""
Validate message content and size.
Validate text message content and size.
Args:
message: Message text
@@ -80,6 +80,81 @@ def validate_message(message: str) -> ValidationResult:
return ValidationResult.ok(length=len(message))
def validate_payload(payload: Union[str, bytes, FilePayload]) -> ValidationResult:
"""
Validate a payload (text message, bytes, or file).
Args:
payload: Text string, raw bytes, or FilePayload
Returns:
ValidationResult
"""
if isinstance(payload, str):
return validate_message(payload)
elif isinstance(payload, FilePayload):
if not payload.data:
return ValidationResult.error("File is empty")
if len(payload.data) > MAX_FILE_PAYLOAD_SIZE:
return ValidationResult.error(
f"File too large ({len(payload.data):,} bytes). "
f"Maximum: {MAX_FILE_PAYLOAD_SIZE:,} bytes ({MAX_FILE_PAYLOAD_SIZE // 1024} KB)"
)
return ValidationResult.ok(
size=len(payload.data),
filename=payload.filename,
mime_type=payload.mime_type
)
elif isinstance(payload, bytes):
if not payload:
return ValidationResult.error("Payload is empty")
if len(payload) > MAX_FILE_PAYLOAD_SIZE:
return ValidationResult.error(
f"Payload too large ({len(payload):,} bytes). "
f"Maximum: {MAX_FILE_PAYLOAD_SIZE:,} bytes ({MAX_FILE_PAYLOAD_SIZE // 1024} KB)"
)
return ValidationResult.ok(size=len(payload))
else:
return ValidationResult.error(f"Invalid payload type: {type(payload)}")
def validate_file_payload(
file_data: bytes,
filename: str = "",
max_size: int = MAX_FILE_PAYLOAD_SIZE
) -> ValidationResult:
"""
Validate a file for embedding.
Args:
file_data: Raw file bytes
filename: Original filename (for display in errors)
max_size: Maximum allowed size in bytes
Returns:
ValidationResult
"""
if not file_data:
return ValidationResult.error("File is empty")
if len(file_data) > max_size:
size_kb = len(file_data) / 1024
max_kb = max_size / 1024
return ValidationResult.error(
f"File '{filename or 'unnamed'}' too large ({size_kb:.1f} KB). "
f"Maximum: {max_kb:.0f} KB"
)
return ValidationResult.ok(size=len(file_data), filename=filename)
def validate_image(
image_data: bytes,
name: str = "Image",
@@ -319,6 +394,13 @@ def require_valid_message(message: str) -> None:
raise MessageValidationError(result.error_message)
def require_valid_payload(payload: Union[str, bytes, FilePayload]) -> None:
"""Validate payload (text, bytes, or file), raising exception on failure."""
result = validate_payload(payload)
if not result.is_valid:
raise MessageValidationError(result.error_message)
def require_valid_image(image_data: bytes, name: str = "Image") -> None:
"""Validate image, raising exception on failure."""
result = validate_image(image_data, name)

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.4 MiB

BIN
test_data/qr_scan.jpg Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 2.5 MiB

BIN
test_data/scandal.txt.gz Normal file

Binary file not shown.

View File

@@ -0,0 +1,353 @@
H4sICKvmUGkCA3NjYW5kYWwudHh0AJV927LjRpLke38Fqh727JqRZ6x3bczaWg9lpUtLp1u3VdVO
WT+CJEhCBwQ4AHgo6o/2O/bH1t0jIjPBU5qxadsdSVUkCCQy4+Lh4fGnP/3pT9V/+L/31Yev3v/4
9fvvq6cfqy9/+u6bH57e/2ffqd6P8/EyVl8Nfd1XXw+3rvmT/+8/+WL1sd50TTXsq+3Qz00/T//p
N/S/r471eW7G6s//tY//z//ax//Xn+7+V3zku/c/f/zml+op/fHHofpwbMZu2D5X3w3dqZmq6dhU
7VTV3bW+TdWM/7oOp7p/rJ6qY/3SVFPT7YZTdWzqcVcd25Nd6IRlaIcefzxWl36H/1v3t2qY+d99
fWrwdfwlrtvc/DeabdeeJ/xH3e/sGuexwZXbvp4b/+HjYOvMq0zNb7jIXF3rqeqHGR+oZ/xFtW+6
mT9m12hOg+6jfm77ah6qbsAt74exehqbvqne77pmfKzed118clrxBuxqQ9/4ndTj3G4vXT12t1V1
bcamqjfHYcQ1Zl6VD7Idut2K97xtp6baXHATu1M7Ymv4rWzqru63za7CI+0eq+8a3voKyzjXz1ji
eaVnPA3TXJ2bcd9s52ps6mno2/6QF2XYTM34wj861dtji2fQrdp7GTu8AqzH1DT9yu4B66mHHit9
4tL5dfTuzl3NG8Jbw1vcV1iiutrXHe7/PEwtl0P32Tf8/nQenrX6/K1p2GNzxepMk63cxIte2/mI
6xzaTaOlrKupb7jKH4/NzRfPV4Y3j0fx88LXwmvbIzbjet38tm26jovMv9uN9ZUPzs+8NG1X7Uds
POy0h6niy3tp/EL61a3e5mP15WVOV57HGiu283XFM+Hd8V7marpsj3j+ebzoUew6+G97t8O1r3ZN
126xFXX1PS7T3fDdXy/TjAvOzQnvrOau14ach3SFcdhdtvhWtWsn/D5uC4+wxz9xT9dji589tYcj
3+A4XPmx4bKZq8uZmxbbEj+fT1Td4danSzfjub4dcdt6Y1PT82Vh6dseP3HhJ1cVLl9XW/zgMz+F
p8Wr81fvT3TE767PA95IhTXG0VvZBtFx2nAr4lXxri/jxta91s/hkfrD8njpPnBQL/iGlhJrgJ/B
ycJi3Rrtz1EbXruSdyMrkhYKW7A4ePZ3Wki8tY6rXpzXFTfh7rJph0thLf790ky8F22rE25svD26
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

View File

@@ -0,0 +1,54 @@
-----BEGIN ENCRYPTED PRIVATE KEY-----
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
-----END ENCRYPTED PRIVATE KEY-----

View File

@@ -22,6 +22,7 @@ from stegasoo import (
decode,
DAY_NAMES,
)
from stegasoo.steganography import get_output_format, get_image_format
class TestKeygen:
@@ -127,19 +128,71 @@ class TestValidation:
assert not result.is_valid
class TestOutputFormat:
"""Test output format detection and preservation."""
def test_png_stays_png(self):
fmt, ext = get_output_format('PNG')
assert fmt == 'PNG'
assert ext == 'png'
def test_bmp_stays_bmp(self):
fmt, ext = get_output_format('BMP')
assert fmt == 'BMP'
assert ext == 'bmp'
def test_jpeg_becomes_png(self):
fmt, ext = get_output_format('JPEG')
assert fmt == 'PNG'
assert ext == 'png'
def test_gif_becomes_png(self):
fmt, ext = get_output_format('GIF')
assert fmt == 'PNG'
assert ext == 'png'
def test_none_becomes_png(self):
fmt, ext = get_output_format(None)
assert fmt == 'PNG'
assert ext == 'png'
def test_unknown_becomes_png(self):
fmt, ext = get_output_format('WEBP')
assert fmt == 'PNG'
assert ext == 'png'
class TestEncodeDecode:
"""Test encoding and decoding (requires test images)."""
@pytest.fixture
def test_image(self):
"""Create a simple test image."""
def png_image(self):
"""Create a simple PNG test image."""
from PIL import Image
img = Image.new('RGB', (100, 100), color='red')
buf = io.BytesIO()
img.save(buf, format='PNG')
return buf.getvalue()
def test_encode_decode_roundtrip(self, test_image):
@pytest.fixture
def bmp_image(self):
"""Create a simple BMP test image."""
from PIL import Image
img = Image.new('RGB', (100, 100), color='blue')
buf = io.BytesIO()
img.save(buf, format='BMP')
return buf.getvalue()
@pytest.fixture
def jpeg_image(self):
"""Create a simple JPEG test image."""
from PIL import Image
img = Image.new('RGB', (100, 100), color='green')
buf = io.BytesIO()
img.save(buf, format='JPEG', quality=95)
return buf.getvalue()
def test_encode_decode_roundtrip(self, png_image):
"""Test full encode/decode cycle."""
message = "Secret message!"
phrase = "apple forest thunder"
@@ -147,8 +200,8 @@ class TestEncodeDecode:
result = encode(
message=message,
reference_photo=test_image,
carrier_image=test_image,
reference_photo=png_image,
carrier_image=png_image,
day_phrase=phrase,
pin=pin
)
@@ -159,19 +212,89 @@ class TestEncodeDecode:
decoded = decode(
stego_image=result.stego_image,
reference_photo=test_image,
reference_photo=png_image,
day_phrase=phrase,
pin=pin
)
assert decoded == message
def test_wrong_pin_fails(self, test_image):
def test_png_carrier_produces_png(self, png_image):
"""Test that PNG carrier produces PNG output."""
result = encode(
message="Test",
reference_photo=png_image,
carrier_image=png_image,
day_phrase="test phrase here",
pin="123456"
)
assert result.filename.endswith('.png')
# Verify actual format
output_format = get_image_format(result.stego_image)
assert output_format == 'PNG'
def test_bmp_carrier_produces_bmp(self, bmp_image, png_image):
"""Test that BMP carrier produces BMP output."""
result = encode(
message="Test",
reference_photo=png_image,
carrier_image=bmp_image,
day_phrase="test phrase here",
pin="123456"
)
assert result.filename.endswith('.bmp')
# Verify actual format
output_format = get_image_format(result.stego_image)
assert output_format == 'BMP'
def test_jpeg_carrier_produces_png(self, jpeg_image, png_image):
"""Test that JPEG carrier produces PNG output (lossy -> lossless)."""
result = encode(
message="Test",
reference_photo=png_image,
carrier_image=jpeg_image,
day_phrase="test phrase here",
pin="123456"
)
assert result.filename.endswith('.png')
# Verify actual format
output_format = get_image_format(result.stego_image)
assert output_format == 'PNG'
def test_bmp_roundtrip(self, bmp_image, png_image):
"""Test full encode/decode cycle with BMP."""
message = "BMP test message!"
phrase = "test phrase words"
pin = "123456"
result = encode(
message=message,
reference_photo=png_image,
carrier_image=bmp_image,
day_phrase=phrase,
pin=pin
)
assert result.filename.endswith('.bmp')
decoded = decode(
stego_image=result.stego_image,
reference_photo=png_image,
day_phrase=phrase,
pin=pin
)
assert decoded == message
def test_wrong_pin_fails(self, png_image):
"""Test that wrong PIN fails to decode."""
result = encode(
message="Secret",
reference_photo=test_image,
carrier_image=test_image,
reference_photo=png_image,
carrier_image=png_image,
day_phrase="test phrase here",
pin="123456"
)
@@ -179,17 +302,17 @@ class TestEncodeDecode:
with pytest.raises(stegasoo.DecryptionError):
decode(
stego_image=result.stego_image,
reference_photo=test_image,
reference_photo=png_image,
day_phrase="test phrase here",
pin="654321" # Wrong PIN
)
def test_wrong_phrase_fails(self, test_image):
def test_wrong_phrase_fails(self, png_image):
"""Test that wrong phrase fails to decode."""
result = encode(
message="Secret",
reference_photo=test_image,
carrier_image=test_image,
reference_photo=png_image,
carrier_image=png_image,
day_phrase="correct phrase here",
pin="123456"
)
@@ -197,7 +320,7 @@ class TestEncodeDecode:
with pytest.raises(stegasoo.DecryptionError):
decode(
stego_image=result.stego_image,
reference_photo=test_image,
reference_photo=png_image,
day_phrase="wrong phrase here",
pin="123456"
)