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:
6
.gitignore
vendored
6
.gitignore
vendored
@@ -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
129
Dockerfile.txt
Normal 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"]
|
||||
@@ -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.")
|
||||
|
||||
@@ -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"
|
||||
"""
|
||||
# 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")
|
||||
|
||||
# Load key if provided
|
||||
# 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
|
||||
"""
|
||||
# Determine what to encode
|
||||
payload = None
|
||||
|
||||
if embed_file:
|
||||
# Binary file embedding
|
||||
payload = FilePayload.from_file(embed_file)
|
||||
if not quiet:
|
||||
click.echo(f"Embedding file: {payload.filename} ({len(payload.data):,} bytes)")
|
||||
elif message:
|
||||
payload = message
|
||||
elif message_file:
|
||||
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 result.is_file:
|
||||
# File content
|
||||
if output:
|
||||
Path(output).write_text(message)
|
||||
out_path = Path(output)
|
||||
elif result.filename:
|
||||
out_path = Path(result.filename)
|
||||
else:
|
||||
out_path = Path("decoded_file")
|
||||
|
||||
if out_path.exists() and not force:
|
||||
raise click.ClickException(
|
||||
f"Output file '{out_path}' exists. Use --force to overwrite."
|
||||
)
|
||||
|
||||
out_path.write_bytes(result.file_data)
|
||||
|
||||
if not quiet:
|
||||
click.secho(f"✓ Decoded successfully!", fg='green')
|
||||
click.secho("✓ Decoded file successfully!", fg='green')
|
||||
click.echo(f" Saved to: {out_path}")
|
||||
click.echo(f" Size: {len(result.file_data):,} bytes")
|
||||
if result.mime_type:
|
||||
click.echo(f" Type: {result.mime_type}")
|
||||
else:
|
||||
# Text content
|
||||
if output:
|
||||
Path(output).write_text(result.message)
|
||||
if not quiet:
|
||||
click.secho("✓ Decoded successfully!", fg='green')
|
||||
click.echo(f" Saved to: {output}")
|
||||
else:
|
||||
if quiet:
|
||||
click.echo(message)
|
||||
click.echo(result.message)
|
||||
else:
|
||||
click.secho("✓ Decoded successfully!", fg='green')
|
||||
click.echo()
|
||||
click.echo(message)
|
||||
click.echo(result.message)
|
||||
|
||||
except (DecryptionError, ExtractionError) as e:
|
||||
raise click.ClickException(f"Decryption failed: {e}")
|
||||
|
||||
@@ -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'])
|
||||
@@ -144,7 +333,7 @@ def download_key():
|
||||
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)
|
||||
@@ -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
|
||||
# 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)
|
||||
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
|
||||
)
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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);
|
||||
}
|
||||
@@ -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">
|
||||
<ul class="list-unstyled">
|
||||
<li class="mb-2">
|
||||
<i class="bi bi-check-circle text-success me-2"></i>
|
||||
<strong>Text & 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">
|
||||
<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 %}
|
||||
<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>
|
||||
<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 %}
|
||||
<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>
|
||||
<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">
|
||||
<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>
|
||||
<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,128 +252,99 @@
|
||||
|
||||
<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 & 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>
|
||||
<table class="table table-dark table-striped">
|
||||
<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>
|
||||
<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-chat-quote text-info me-2"></i>3-Word Phrase</td>
|
||||
<td>~33 bits</td>
|
||||
<td>Something you know (changes daily)</td>
|
||||
<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-123 text-info me-2"></i>6-Digit PIN</td>
|
||||
<td>~20 bits</td>
|
||||
<td>Something you know (static)</td>
|
||||
<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-calendar text-info me-2"></i>Date</td>
|
||||
<td>N/A</td>
|
||||
<td>Automatic key rotation</td>
|
||||
<td><i class="bi bi-upload me-2"></i>Max upload size</td>
|
||||
<td><strong>10 MB</strong></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>
|
||||
<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>
|
||||
|
||||
<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>
|
||||
</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 & 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>
|
||||
<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 •
|
||||
<i class="bi bi-github me-1"></i>Open Source •
|
||||
Built with Python, Flask, and cryptography
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
<div class="mb-4">
|
||||
<label class="form-label text-muted">Decoded Message:</label>
|
||||
<div class="alert-message">{{ decoded_message }}</div>
|
||||
<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>
|
||||
|
||||
{% 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() {
|
||||
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"]');
|
||||
|
||||
@@ -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)
|
||||
<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,12 +175,12 @@
|
||||
<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>
|
||||
|
||||
@@ -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');
|
||||
|
||||
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;
|
||||
input.dispatchEvent(new Event('change'));
|
||||
|
||||
if (!isPayloadZone) {
|
||||
showPreview(e.dataTransfer.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 => {
|
||||
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 %}
|
||||
|
||||
@@ -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] });
|
||||
}
|
||||
|
||||
shareBtn.addEventListener('click', async function() {
|
||||
const canShare = await canShareFiles();
|
||||
|
||||
if (canShare) {
|
||||
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 {
|
||||
// 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',
|
||||
title: 'Stegasoo 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');
|
||||
});
|
||||
}
|
||||
})
|
||||
.catch(err => console.log('Could not load file for sharing'));
|
||||
}
|
||||
});
|
||||
|
||||
// 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 %}
|
||||
@@ -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.
|
||||
<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>
|
||||
|
||||
<!-- 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
|
||||
<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>
|
||||
|
||||
<!-- 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 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 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>
|
||||
</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="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-hover">
|
||||
<thead>
|
||||
<tr>
|
||||
<th style="width: 140px;">Day</th>
|
||||
<th>Phrase</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<table class="table table-dark table-striped mb-0">
|
||||
<tbody>
|
||||
{% for day in days %}
|
||||
<tr>
|
||||
<td class="text-nowrap">
|
||||
<i class="bi bi-calendar3 me-2"></i>{{ day }}
|
||||
</td>
|
||||
<td class="text-muted" style="width: 100px;">{{ day }}</td>
|
||||
<td>
|
||||
<span class="phrase-display">{{ phrases[day] }}</span>
|
||||
<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>
|
||||
</div>
|
||||
{% if pin %}
|
||||
<div class="col-3">
|
||||
<div class="fs-4 fw-bold">{{ pin_entropy }}</div>
|
||||
<small class="text-muted">bits/PIN</small>
|
||||
</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 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 %}
|
||||
<div class="col-3">
|
||||
<div class="fs-4 fw-bold text-success">{{ total_entropy }}</div>
|
||||
<small class="text-muted">bits total</small>
|
||||
</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>
|
||||
</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>
|
||||
|
||||
<a href="/generate" class="btn btn-outline-light btn-lg w-100 mt-3">
|
||||
<!-- 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 %}
|
||||
</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 %}
|
||||
{% 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>
|
||||
<div class="form-text text-center mt-2">
|
||||
+ reference photo entropy (~80-256 bits)
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<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() {
|
||||
if (usePinCheck) {
|
||||
usePinCheck.addEventListener('change', function() {
|
||||
pinOptions.classList.toggle('d-none', !this.checked);
|
||||
validateFactors();
|
||||
updateEntropy();
|
||||
});
|
||||
}
|
||||
|
||||
useRsaCheckbox.addEventListener('change', function() {
|
||||
if (useRsaCheck) {
|
||||
useRsaCheck.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;
|
||||
}
|
||||
|
||||
function updateEntropy() {
|
||||
const words = parseInt(document.getElementById('wordsSelect').value);
|
||||
const usePin = usePinCheckbox.checked;
|
||||
const useRsa = useRsaCheckbox.checked;
|
||||
const pinLen = parseInt(document.getElementById('pinSelect').value);
|
||||
// PIN visibility toggle
|
||||
let pinHidden = false;
|
||||
function togglePinVisibility() {
|
||||
const pinDigits = document.getElementById('pinDigits');
|
||||
const icon = document.getElementById('pinToggleIcon');
|
||||
const text = document.getElementById('pinToggleText');
|
||||
|
||||
const phraseEntropy = words * 11;
|
||||
const pinEntropy = usePin ? Math.floor(pinLen * 3.32) : 0;
|
||||
const rsaEntropy = useRsa ? 128 : 0;
|
||||
const total = phraseEntropy + pinEntropy + rsaEntropy;
|
||||
pinHidden = !pinHidden;
|
||||
|
||||
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 (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';
|
||||
}
|
||||
}
|
||||
|
||||
document.getElementById('wordsSelect').addEventListener('change', updateEntropy);
|
||||
document.getElementById('pinSelect').addEventListener('change', updateEntropy);
|
||||
document.getElementById('rsaSelect').addEventListener('change', updateEntropy);
|
||||
// Copy PIN
|
||||
function copyPin() {
|
||||
const pin = '{{ pin|default("", true) }}';
|
||||
const icon = document.getElementById('pinCopyIcon');
|
||||
const text = document.getElementById('pinCopyText');
|
||||
|
||||
// Form submit
|
||||
document.getElementById('generateForm').addEventListener('submit', function(e) {
|
||||
if (!usePinCheckbox.checked && !useRsaCheckbox.checked) {
|
||||
e.preventDefault();
|
||||
noFactorWarning.classList.remove('d-none');
|
||||
return;
|
||||
}
|
||||
|
||||
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 %}
|
||||
|
||||
@@ -8,59 +8,59 @@
|
||||
<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">
|
||||
<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"></i>
|
||||
<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>
|
||||
<a href="/encode" class="btn btn-primary">
|
||||
<i class="bi bi-upload me-1"></i> Encode
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Decode Card -->
|
||||
<div class="col-md-4">
|
||||
<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"></i>
|
||||
<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>
|
||||
<a href="/decode" class="btn btn-primary">
|
||||
<i class="bi bi-download me-1"></i> Decode
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Generate Card -->
|
||||
<div class="col-md-4">
|
||||
<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"></i>
|
||||
<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>
|
||||
<a href="/generate" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle me-1"></i> Generate
|
||||
</div>
|
||||
</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
|
||||
@@ -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]",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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',
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -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
180
src/stegasoo/debug.py
Normal 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()
|
||||
@@ -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,18 +230,29 @@ 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(
|
||||
use_pin: bool = True,
|
||||
@@ -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
|
||||
|
||||
@@ -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
397
src/stegasoo/qr_utils.py
Normal 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
|
||||
@@ -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:
|
||||
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,6 +256,8 @@ 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_val != new_val:
|
||||
modified = True
|
||||
if channel_idx == 0:
|
||||
r = new_val
|
||||
elif channel_idx == 1:
|
||||
@@ -154,31 +267,47 @@ def embed_in_image(
|
||||
|
||||
bit_idx += bits_per_channel
|
||||
|
||||
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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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
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
BIN
test_data/scandal.txt.gz
Normal file
Binary file not shown.
353
test_data/scandal.txt.gz.b64
Normal file
353
test_data/scandal.txt.gz.b64
Normal file
@@ -0,0 +1,353 @@
|
||||
H4sICKvmUGkCA3NjYW5kYWwudHh0AJV927LjRpLke38Fqh727JqRZ6x3bczaWg9lpUtLp1u3VdVO
|
||||
WT+CJEhCBwQ4AHgo6o/2O/bH1t0jIjPBU5qxadsdSVUkCCQy4+Lh4fGnP/3pT9V/+L/31Yev3v/4
|
||||
9fvvq6cfqy9/+u6bH57e/2ffqd6P8/EyVl8Nfd1XXw+3rvmT/+8/+WL1sd50TTXsq+3Qz00/T//p
|
||||
N/S/r471eW7G6s//tY//z//ax//Xn+7+V3zku/c/f/zml+op/fHHofpwbMZu2D5X3w3dqZmq6dhU
|
||||
7VTV3bW+TdWM/7oOp7p/rJ6qY/3SVFPT7YZTdWzqcVcd25Nd6IRlaIcefzxWl36H/1v3t2qY+d99
|
||||
fWrwdfwlrtvc/DeabdeeJ/xH3e/sGuexwZXbvp4b/+HjYOvMq0zNb7jIXF3rqeqHGR+oZ/xFtW+6
|
||||
mT9m12hOg+6jfm77ah6qbsAt74exehqbvqne77pmfKzed118clrxBuxqQ9/4ndTj3G4vXT12t1V1
|
||||
bcamqjfHYcQ1Zl6VD7Idut2K97xtp6baXHATu1M7Ymv4rWzqru63za7CI+0eq+8a3voKyzjXz1ji
|
||||
eaVnPA3TXJ2bcd9s52ps6mno2/6QF2XYTM34wj861dtji2fQrdp7GTu8AqzH1DT9yu4B66mHHit9
|
||||
4tL5dfTuzl3NG8Jbw1vcV1iiutrXHe7/PEwtl0P32Tf8/nQenrX6/K1p2GNzxepMk63cxIte2/mI
|
||||
6xzaTaOlrKupb7jKH4/NzRfPV4Y3j0fx88LXwmvbIzbjet38tm26jovMv9uN9ZUPzs+8NG1X7Uds
|
||||
POy0h6niy3tp/EL61a3e5mP15WVOV57HGiu283XFM+Hd8V7marpsj3j+ebzoUew6+G97t8O1r3ZN
|
||||
126xFXX1PS7T3fDdXy/TjAvOzQnvrOau14ach3SFcdhdtvhWtWsn/D5uC4+wxz9xT9dji589tYcj
|
||||
3+A4XPmx4bKZq8uZmxbbEj+fT1Td4danSzfjub4dcdt6Y1PT82Vh6dseP3HhJ1cVLl9XW/zgMz+F
|
||||
p8Wr81fvT3TE767PA95IhTXG0VvZBtFx2nAr4lXxri/jxta91s/hkfrD8njpPnBQL/iGlhJrgJ/B
|
||||
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
|
||||
54
test_data/stegasoo_key_4096_5e663335.pem
Normal file
54
test_data/stegasoo_key_4096_5e663335.pem
Normal file
@@ -0,0 +1,54 @@
|
||||
-----BEGIN ENCRYPTED PRIVATE KEY-----
|
||||
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
|
||||
-----END ENCRYPTED PRIVATE KEY-----
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user