Add per-channel hybrid audio spread spectrum and env feature toggles
Spread spectrum v2: independent per-channel embedding with round-robin bit distribution, preserving spatial stereo/surround mix. Adaptive chip tiers (256/512/1024) trade capacity for lossy codec robustness. LFE channel skipped for 5.1+ layouts. v2 header (20B) with backward- compatible v0 decode fallback. Environment toggles (STEGASOO_AUDIO, STEGASOO_VIDEO) gate audio/video features for minimal builds (e.g. Raspberry Pi image-only). Values: auto (default, detect deps), 1/true (force on), 0/false (force off). Web UI fixes: accordion defaults to step 1 on load, chevron arrow styling, required attribute toggling for audio carrier type switch, "Images & Mode" renamed to "Reference, Carrier, Mode". Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -146,6 +146,7 @@ sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||||
|
||||
import stegasoo
|
||||
from stegasoo import (
|
||||
HAS_AUDIO_SUPPORT,
|
||||
CapacityError,
|
||||
DecryptionError,
|
||||
FilePayload,
|
||||
@@ -463,6 +464,9 @@ def inject_globals():
|
||||
"is_admin": is_admin(),
|
||||
# NEW in v4.2.0 - Saved channel keys
|
||||
"saved_channel_keys": saved_channel_keys,
|
||||
# NEW in v4.3.0 - Audio support
|
||||
"has_audio": HAS_AUDIO_SUPPORT,
|
||||
"supported_audio_formats": "WAV, FLAC, MP3, OGG, AAC, M4A" if HAS_AUDIO_SUPPORT else "",
|
||||
}
|
||||
|
||||
|
||||
@@ -564,6 +568,14 @@ def allowed_image(filename: str) -> bool:
|
||||
return ext in {"png", "jpg", "jpeg", "bmp", "gif"}
|
||||
|
||||
|
||||
def allowed_audio(filename: str) -> bool:
|
||||
"""Check if file has allowed audio extension."""
|
||||
if not filename or "." not in filename:
|
||||
return False
|
||||
ext = filename.rsplit(".", 1)[1].lower()
|
||||
return ext in {"wav", "flac", "mp3", "ogg", "aac", "m4a", "aiff", "aif"}
|
||||
|
||||
|
||||
def format_size(size_bytes: int) -> str:
|
||||
"""Format file size for display."""
|
||||
if size_bytes < 1024:
|
||||
@@ -710,11 +722,15 @@ def generate():
|
||||
if not qr_too_large:
|
||||
qr_token = secrets.token_urlsafe(16)
|
||||
cleanup_temp_files()
|
||||
temp_storage.save_temp_file(qr_token, creds.rsa_key_pem.encode(), {
|
||||
"filename": "rsa_key.pem",
|
||||
"type": "rsa_key",
|
||||
"compress": qr_needs_compression,
|
||||
})
|
||||
temp_storage.save_temp_file(
|
||||
qr_token,
|
||||
creds.rsa_key_pem.encode(),
|
||||
{
|
||||
"filename": "rsa_key.pem",
|
||||
"type": "rsa_key",
|
||||
"compress": qr_needs_compression,
|
||||
},
|
||||
)
|
||||
|
||||
# v3.2.0: Single passphrase instead of daily phrases
|
||||
return render_template(
|
||||
@@ -1001,6 +1017,37 @@ def api_check_fit():
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
@app.route("/api/audio-capacity", methods=["POST"])
|
||||
@login_required
|
||||
def api_audio_capacity():
|
||||
"""Get audio file capacity for steganography (v4.3.0)."""
|
||||
audio_file = request.files.get("carrier")
|
||||
if not audio_file:
|
||||
return jsonify({"error": "No audio file provided"}), 400
|
||||
|
||||
try:
|
||||
audio_data = audio_file.read()
|
||||
result = subprocess_stego.audio_info(audio_data)
|
||||
|
||||
if not result.success:
|
||||
return jsonify({"error": result.error or "Audio analysis failed"}), 500
|
||||
|
||||
return jsonify(
|
||||
{
|
||||
"success": True,
|
||||
"sample_rate": result.sample_rate,
|
||||
"channels": result.channels,
|
||||
"duration": round(result.duration_seconds, 2),
|
||||
"format": result.format,
|
||||
"bit_depth": result.bit_depth,
|
||||
"lsb_capacity": result.capacity_lsb,
|
||||
"spread_capacity": result.capacity_spread,
|
||||
}
|
||||
)
|
||||
except Exception as e:
|
||||
return jsonify({"error": str(e)}), 500
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# ENCODE
|
||||
# ============================================================================
|
||||
@@ -1078,15 +1125,105 @@ def _run_encode_job(job_id: str, encode_params: dict) -> None:
|
||||
|
||||
# Store result
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
temp_storage.save_temp_file(file_id, encode_result.stego_data, {
|
||||
"filename": filename,
|
||||
"embed_mode": embed_mode,
|
||||
"output_format": dct_output_format if embed_mode == "dct" else "png",
|
||||
"color_mode": dct_color_mode if embed_mode == "dct" else None,
|
||||
"mime_type": output_mime,
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
})
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
encode_result.stego_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"embed_mode": embed_mode,
|
||||
"output_format": dct_output_format if embed_mode == "dct" else "png",
|
||||
"color_mode": dct_color_mode if embed_mode == "dct" else None,
|
||||
"mime_type": output_mime,
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
},
|
||||
)
|
||||
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "complete",
|
||||
"file_id": file_id,
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
cleanup_progress_file(job_id)
|
||||
|
||||
|
||||
def _run_encode_audio_job(job_id: str, encode_params: dict) -> None:
|
||||
"""Background thread function for async audio encode (v4.3.0)."""
|
||||
progress_file = get_progress_file_path(job_id)
|
||||
|
||||
try:
|
||||
_store_job(job_id, {"status": "running", "created": time.time()})
|
||||
|
||||
if encode_params.get("file_data"):
|
||||
encode_result = subprocess_stego.encode_audio(
|
||||
carrier_data=encode_params["carrier_data"],
|
||||
reference_data=encode_params["ref_data"],
|
||||
file_data=encode_params["file_data"],
|
||||
file_name=encode_params["file_name"],
|
||||
file_mime=encode_params["file_mime"],
|
||||
passphrase=encode_params["passphrase"],
|
||||
pin=encode_params.get("pin"),
|
||||
rsa_key_data=encode_params.get("rsa_key_data"),
|
||||
rsa_password=encode_params.get("key_password"),
|
||||
embed_mode=encode_params["embed_mode"],
|
||||
channel_key=encode_params.get("channel_key"),
|
||||
progress_file=progress_file,
|
||||
chip_tier=encode_params.get("chip_tier"),
|
||||
)
|
||||
else:
|
||||
encode_result = subprocess_stego.encode_audio(
|
||||
carrier_data=encode_params["carrier_data"],
|
||||
reference_data=encode_params["ref_data"],
|
||||
message=encode_params.get("message"),
|
||||
passphrase=encode_params["passphrase"],
|
||||
pin=encode_params.get("pin"),
|
||||
rsa_key_data=encode_params.get("rsa_key_data"),
|
||||
rsa_password=encode_params.get("key_password"),
|
||||
embed_mode=encode_params["embed_mode"],
|
||||
channel_key=encode_params.get("channel_key"),
|
||||
progress_file=progress_file,
|
||||
chip_tier=encode_params.get("chip_tier"),
|
||||
)
|
||||
|
||||
if not encode_result.success:
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "error",
|
||||
"error": encode_result.error or "Audio encoding failed",
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
filename = generate_filename("stego_audio", ".wav")
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
encode_result.stego_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"embed_mode": encode_params["embed_mode"],
|
||||
"carrier_type": "audio",
|
||||
"mime_type": "audio/wav",
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
},
|
||||
)
|
||||
|
||||
_store_job(
|
||||
job_id,
|
||||
@@ -1131,6 +1268,196 @@ def encode_page():
|
||||
rsa_key_file = request.files.get("rsa_key")
|
||||
payload_file = request.files.get("payload_file")
|
||||
|
||||
# Determine carrier type (v4.3.0)
|
||||
carrier_type = request.form.get("carrier_type", "image")
|
||||
|
||||
if carrier_type == "audio":
|
||||
# ========== AUDIO ENCODE PATH (v4.3.0) ==========
|
||||
if not HAS_AUDIO_SUPPORT:
|
||||
return _error_response(
|
||||
"Audio steganography is not available. Install audio dependencies."
|
||||
)
|
||||
|
||||
if not ref_photo or not carrier:
|
||||
return _error_response("Both reference photo and audio carrier are required")
|
||||
|
||||
if not allowed_image(ref_photo.filename):
|
||||
return _error_response("Reference must be an image (PNG, JPG, BMP)")
|
||||
|
||||
if not allowed_audio(carrier.filename):
|
||||
return _error_response(
|
||||
"Invalid audio format. Use WAV, FLAC, MP3, OGG, AAC, or M4A"
|
||||
)
|
||||
|
||||
# Get form data
|
||||
message = request.form.get("message", "")
|
||||
passphrase = request.form.get("passphrase", "")
|
||||
pin = request.form.get("pin", "").strip()
|
||||
rsa_password = request.form.get("rsa_password", "")
|
||||
payload_type = request.form.get("payload_type", "text")
|
||||
|
||||
embed_mode = request.form.get("embed_mode", "audio_lsb")
|
||||
if embed_mode not in ("audio_lsb", "audio_spread"):
|
||||
embed_mode = "audio_lsb"
|
||||
|
||||
# Chip tier for spread spectrum (None = default)
|
||||
chip_tier_str = request.form.get("chip_tier")
|
||||
chip_tier = None
|
||||
if chip_tier_str and chip_tier_str.isdigit():
|
||||
chip_tier = int(chip_tier_str)
|
||||
if chip_tier not in (0, 1, 2):
|
||||
chip_tier = None
|
||||
|
||||
channel_key = resolve_channel_key_form(request.form.get("channel_key", "auto"))
|
||||
|
||||
# Determine payload
|
||||
if payload_type == "file" and payload_file and payload_file.filename:
|
||||
file_data = payload_file.read()
|
||||
result = validate_file_payload(file_data, payload_file.filename)
|
||||
if not result.is_valid:
|
||||
return _error_response(result.error_message)
|
||||
mime_type, _ = mimetypes.guess_type(payload_file.filename)
|
||||
payload = FilePayload(
|
||||
data=file_data,
|
||||
filename=payload_file.filename,
|
||||
mime_type=mime_type,
|
||||
)
|
||||
else:
|
||||
result = validate_message(message)
|
||||
if not result.is_valid:
|
||||
return _error_response(result.error_message)
|
||||
payload = message
|
||||
|
||||
if not passphrase:
|
||||
return _error_response("Passphrase is required")
|
||||
|
||||
result = validate_passphrase(passphrase)
|
||||
if not result.is_valid:
|
||||
return _error_response(result.error_message)
|
||||
if result.warning:
|
||||
flash(result.warning, "warning")
|
||||
|
||||
ref_data = ref_photo.read()
|
||||
carrier_data = carrier.read()
|
||||
|
||||
# Handle RSA key (same as image path)
|
||||
rsa_key_data = None
|
||||
rsa_key_pem = request.form.get("rsa_key_pem", "").strip()
|
||||
rsa_key_qr = request.files.get("rsa_key_qr")
|
||||
rsa_key_from_qr = False
|
||||
|
||||
if rsa_key_pem:
|
||||
if is_compressed(rsa_key_pem):
|
||||
rsa_key_pem = decompress_data(rsa_key_pem)
|
||||
rsa_key_data = rsa_key_pem.encode("utf-8")
|
||||
rsa_key_from_qr = True
|
||||
elif rsa_key_file and rsa_key_file.filename:
|
||||
rsa_key_data = rsa_key_file.read()
|
||||
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
|
||||
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
|
||||
else:
|
||||
return _error_response("Could not extract RSA key from QR code image.")
|
||||
|
||||
result = validate_security_factors(pin, rsa_key_data)
|
||||
if not result.is_valid:
|
||||
return _error_response(result.error_message)
|
||||
|
||||
if pin:
|
||||
result = validate_pin(pin)
|
||||
if not result.is_valid:
|
||||
return _error_response(result.error_message)
|
||||
|
||||
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
|
||||
|
||||
if rsa_key_data:
|
||||
result = validate_rsa_key(rsa_key_data, key_password)
|
||||
if not result.is_valid:
|
||||
return _error_response(result.error_message)
|
||||
|
||||
# Build audio encode params
|
||||
encode_params = {
|
||||
"carrier_data": carrier_data,
|
||||
"ref_data": ref_data,
|
||||
"passphrase": passphrase,
|
||||
"pin": pin if pin else None,
|
||||
"rsa_key_data": rsa_key_data,
|
||||
"key_password": key_password,
|
||||
"embed_mode": embed_mode,
|
||||
"channel_key": channel_key,
|
||||
"carrier_type": "audio",
|
||||
"chip_tier": chip_tier,
|
||||
}
|
||||
|
||||
if payload_type == "file" and payload_file and payload_file.filename:
|
||||
encode_params["file_data"] = payload.data
|
||||
encode_params["file_name"] = payload.filename
|
||||
encode_params["file_mime"] = payload.mime_type
|
||||
else:
|
||||
encode_params["message"] = payload
|
||||
|
||||
if is_async:
|
||||
job_id = generate_job_id()
|
||||
_store_job(job_id, {"status": "pending", "created": time.time()})
|
||||
_executor.submit(_run_encode_audio_job, job_id, encode_params)
|
||||
return jsonify({"job_id": job_id, "status": "pending"})
|
||||
|
||||
# Sync audio encode
|
||||
if encode_params.get("file_data"):
|
||||
encode_result = subprocess_stego.encode_audio(
|
||||
carrier_data=carrier_data,
|
||||
reference_data=ref_data,
|
||||
file_data=encode_params["file_data"],
|
||||
file_name=encode_params["file_name"],
|
||||
file_mime=encode_params["file_mime"],
|
||||
passphrase=passphrase,
|
||||
pin=pin if pin else None,
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=key_password,
|
||||
embed_mode=embed_mode,
|
||||
channel_key=channel_key,
|
||||
chip_tier=chip_tier,
|
||||
)
|
||||
else:
|
||||
encode_result = subprocess_stego.encode_audio(
|
||||
carrier_data=carrier_data,
|
||||
reference_data=ref_data,
|
||||
message=payload,
|
||||
passphrase=passphrase,
|
||||
pin=pin if pin else None,
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=key_password,
|
||||
embed_mode=embed_mode,
|
||||
channel_key=channel_key,
|
||||
chip_tier=chip_tier,
|
||||
)
|
||||
|
||||
if not encode_result.success:
|
||||
error_msg = encode_result.error or "Audio encoding failed"
|
||||
return _error_response(error_msg)
|
||||
|
||||
filename = generate_filename("stego_audio", ".wav")
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
cleanup_temp_files()
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
encode_result.stego_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"embed_mode": embed_mode,
|
||||
"carrier_type": "audio",
|
||||
"mime_type": "audio/wav",
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
},
|
||||
)
|
||||
|
||||
return redirect(url_for("encode_result", file_id=file_id))
|
||||
|
||||
# ========== IMAGE ENCODE PATH (original) ==========
|
||||
if not ref_photo or not carrier:
|
||||
return _error_response("Both reference photo and carrier image are required")
|
||||
|
||||
@@ -1356,16 +1683,20 @@ def encode_page():
|
||||
# Store temporarily
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
cleanup_temp_files()
|
||||
temp_storage.save_temp_file(file_id, encode_result.stego_data, {
|
||||
"filename": filename,
|
||||
"embed_mode": embed_mode,
|
||||
"output_format": dct_output_format if embed_mode == "dct" else "png",
|
||||
"color_mode": dct_color_mode if embed_mode == "dct" else None,
|
||||
"mime_type": output_mime,
|
||||
# Channel info (v4.0.0)
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
})
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
encode_result.stego_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"embed_mode": embed_mode,
|
||||
"output_format": dct_output_format if embed_mode == "dct" else "png",
|
||||
"color_mode": dct_color_mode if embed_mode == "dct" else None,
|
||||
"mime_type": output_mime,
|
||||
# Channel info (v4.0.0)
|
||||
"channel_mode": encode_result.channel_mode,
|
||||
"channel_fingerprint": encode_result.channel_fingerprint,
|
||||
},
|
||||
)
|
||||
|
||||
return redirect(url_for("encode_result", file_id=file_id))
|
||||
|
||||
@@ -1434,13 +1765,16 @@ def encode_result(file_id):
|
||||
flash("File expired or not found. Please encode again.", "error")
|
||||
return redirect(url_for("encode_page"))
|
||||
|
||||
# Generate thumbnail
|
||||
thumbnail_data = generate_thumbnail(file_info["data"])
|
||||
thumbnail_id = None
|
||||
carrier_type = file_info.get("carrier_type", "image")
|
||||
|
||||
if thumbnail_data:
|
||||
thumbnail_id = f"{file_id}_thumb"
|
||||
temp_storage.save_thumbnail(thumbnail_id, thumbnail_data)
|
||||
# Generate thumbnail only for images
|
||||
thumbnail_data = None
|
||||
thumbnail_id = None
|
||||
if carrier_type != "audio":
|
||||
thumbnail_data = generate_thumbnail(file_info["data"])
|
||||
if thumbnail_data:
|
||||
thumbnail_id = f"{file_id}_thumb"
|
||||
temp_storage.save_thumbnail(thumbnail_id, thumbnail_data)
|
||||
|
||||
return render_template(
|
||||
"encode_result.html",
|
||||
@@ -1450,6 +1784,7 @@ def encode_result(file_id):
|
||||
embed_mode=file_info.get("embed_mode", "lsb"),
|
||||
output_format=file_info.get("output_format", "png"),
|
||||
color_mode=file_info.get("color_mode"),
|
||||
carrier_type=carrier_type,
|
||||
# Channel info (v4.0.0)
|
||||
channel_mode=file_info.get("channel_mode", "public"),
|
||||
channel_fingerprint=file_info.get("channel_fingerprint"),
|
||||
@@ -1464,9 +1799,7 @@ def encode_thumbnail(thumb_id):
|
||||
if not thumb_data:
|
||||
return "Thumbnail not found", 404
|
||||
|
||||
return send_file(
|
||||
io.BytesIO(thumb_data), mimetype="image/jpeg", as_attachment=False
|
||||
)
|
||||
return send_file(io.BytesIO(thumb_data), mimetype="image/jpeg", as_attachment=False)
|
||||
|
||||
|
||||
@app.route("/encode/download/<file_id>")
|
||||
@@ -1559,10 +1892,92 @@ def _run_decode_job(job_id: str, decode_params: dict) -> None:
|
||||
if decode_result.is_file:
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
filename = decode_result.filename or "decoded_file"
|
||||
temp_storage.save_temp_file(file_id, decode_result.file_data, {
|
||||
"filename": filename,
|
||||
"mime_type": decode_result.mime_type,
|
||||
})
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
decode_result.file_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"mime_type": decode_result.mime_type,
|
||||
},
|
||||
)
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "complete",
|
||||
"file_id": file_id,
|
||||
"is_file": True,
|
||||
"filename": filename,
|
||||
"file_size": len(decode_result.file_data),
|
||||
"mime_type": decode_result.mime_type,
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
else:
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "complete",
|
||||
"is_file": False,
|
||||
"message": decode_result.message,
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "error",
|
||||
"error": str(e),
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
finally:
|
||||
cleanup_progress_file(job_id)
|
||||
|
||||
|
||||
def _run_decode_audio_job(job_id: str, decode_params: dict) -> None:
|
||||
"""Background thread function for async audio decode (v4.3.0)."""
|
||||
progress_file = get_progress_file_path(job_id)
|
||||
|
||||
try:
|
||||
_store_job(job_id, {"status": "running", "created": time.time()})
|
||||
|
||||
decode_result = subprocess_stego.decode_audio(
|
||||
stego_data=decode_params["stego_data"],
|
||||
reference_data=decode_params["ref_data"],
|
||||
passphrase=decode_params["passphrase"],
|
||||
pin=decode_params.get("pin"),
|
||||
rsa_key_data=decode_params.get("rsa_key_data"),
|
||||
rsa_password=decode_params.get("rsa_password"),
|
||||
embed_mode=decode_params.get("embed_mode", "audio_auto"),
|
||||
channel_key=decode_params.get("channel_key"),
|
||||
progress_file=progress_file,
|
||||
)
|
||||
|
||||
if not decode_result.success:
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
"status": "error",
|
||||
"error": decode_result.error or "Audio decoding failed",
|
||||
"error_type": decode_result.error_type,
|
||||
"created": time.time(),
|
||||
},
|
||||
)
|
||||
return
|
||||
|
||||
if decode_result.is_file:
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
filename = decode_result.filename or "decoded_file"
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
decode_result.file_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"mime_type": decode_result.mime_type,
|
||||
},
|
||||
)
|
||||
_store_job(
|
||||
job_id,
|
||||
{
|
||||
@@ -1609,6 +2024,163 @@ def decode_page():
|
||||
stego_image = request.files.get("stego_image")
|
||||
rsa_key_file = request.files.get("rsa_key")
|
||||
|
||||
# Determine carrier type (v4.3.0)
|
||||
carrier_type = request.form.get("carrier_type", "image")
|
||||
|
||||
if carrier_type == "audio":
|
||||
# ========== AUDIO DECODE PATH (v4.3.0) ==========
|
||||
if not HAS_AUDIO_SUPPORT:
|
||||
flash("Audio steganography is not available.", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
if not ref_photo or not stego_image:
|
||||
flash("Both reference photo and stego audio are required", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
if not allowed_image(ref_photo.filename):
|
||||
flash("Reference must be an image", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
if not allowed_audio(stego_image.filename):
|
||||
flash("Invalid audio format", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
passphrase = request.form.get("passphrase", "")
|
||||
pin = request.form.get("pin", "").strip()
|
||||
rsa_password = request.form.get("rsa_password", "")
|
||||
|
||||
embed_mode = request.form.get("embed_mode", "audio_auto")
|
||||
if embed_mode not in ("audio_auto", "audio_lsb", "audio_spread"):
|
||||
embed_mode = "audio_auto"
|
||||
|
||||
channel_key = resolve_channel_key_form(request.form.get("channel_key", "auto"))
|
||||
|
||||
if not passphrase:
|
||||
flash("Passphrase is required", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
ref_data = ref_photo.read()
|
||||
stego_data = stego_image.read()
|
||||
|
||||
# Handle RSA key (same as image path)
|
||||
rsa_key_data = None
|
||||
rsa_key_pem = request.form.get("rsa_key_pem", "").strip()
|
||||
rsa_key_qr = request.files.get("rsa_key_qr")
|
||||
rsa_key_from_qr = False
|
||||
|
||||
if rsa_key_pem:
|
||||
if is_compressed(rsa_key_pem):
|
||||
rsa_key_pem = decompress_data(rsa_key_pem)
|
||||
rsa_key_data = rsa_key_pem.encode("utf-8")
|
||||
rsa_key_from_qr = True
|
||||
elif rsa_key_file and rsa_key_file.filename:
|
||||
rsa_key_data = rsa_key_file.read()
|
||||
elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ:
|
||||
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
|
||||
else:
|
||||
flash("Could not extract RSA key from QR code image.", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
result = validate_security_factors(pin, rsa_key_data)
|
||||
if not result.is_valid:
|
||||
flash(result.error_message, "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
if pin:
|
||||
result = validate_pin(pin)
|
||||
if not result.is_valid:
|
||||
flash(result.error_message, "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
|
||||
|
||||
if rsa_key_data:
|
||||
result = validate_rsa_key(rsa_key_data, key_password)
|
||||
if not result.is_valid:
|
||||
flash(result.error_message, "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
is_async = (
|
||||
request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
|
||||
)
|
||||
|
||||
decode_params = {
|
||||
"stego_data": stego_data,
|
||||
"ref_data": ref_data,
|
||||
"passphrase": passphrase,
|
||||
"pin": pin if pin else None,
|
||||
"rsa_key_data": rsa_key_data,
|
||||
"rsa_password": key_password,
|
||||
"embed_mode": embed_mode,
|
||||
"channel_key": channel_key,
|
||||
}
|
||||
|
||||
if is_async:
|
||||
job_id = generate_job_id()
|
||||
_store_job(job_id, {"status": "pending", "created": time.time()})
|
||||
_executor.submit(_run_decode_audio_job, job_id, decode_params)
|
||||
return jsonify({"job_id": job_id, "status": "pending"})
|
||||
|
||||
# Sync audio decode
|
||||
decode_result = subprocess_stego.decode_audio(
|
||||
stego_data=stego_data,
|
||||
reference_data=ref_data,
|
||||
passphrase=passphrase,
|
||||
pin=pin if pin else None,
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=key_password,
|
||||
embed_mode=embed_mode,
|
||||
channel_key=channel_key,
|
||||
)
|
||||
|
||||
if not decode_result.success:
|
||||
error_msg = decode_result.error or "Audio decoding failed"
|
||||
if (
|
||||
"decrypt" in error_msg.lower()
|
||||
or decode_result.error_type == "DecryptionError"
|
||||
):
|
||||
flash(
|
||||
"Wrong credentials. Double-check your reference photo, "
|
||||
"passphrase, PIN, and channel key.",
|
||||
"warning",
|
||||
)
|
||||
else:
|
||||
flash(error_msg, "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
if decode_result.is_file:
|
||||
file_id = secrets.token_urlsafe(16)
|
||||
cleanup_temp_files()
|
||||
filename = decode_result.filename or "decoded_file"
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
decode_result.file_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"mime_type": decode_result.mime_type,
|
||||
},
|
||||
)
|
||||
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,
|
||||
has_qrcode_read=HAS_QRCODE_READ,
|
||||
)
|
||||
else:
|
||||
return render_template(
|
||||
"decode.html",
|
||||
decoded_message=decode_result.message,
|
||||
has_qrcode_read=HAS_QRCODE_READ,
|
||||
)
|
||||
|
||||
# ========== IMAGE DECODE PATH (original) ==========
|
||||
if not ref_photo or not stego_image:
|
||||
flash("Both reference photo and stego image are required", "error")
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
@@ -1690,7 +2262,9 @@ def decode_page():
|
||||
return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ)
|
||||
|
||||
# Check for async mode (v4.1.5)
|
||||
is_async = request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
|
||||
is_async = (
|
||||
request.form.get("async") == "true" or request.headers.get("X-Async") == "true"
|
||||
)
|
||||
|
||||
# Build decode params
|
||||
decode_params = {
|
||||
@@ -1742,10 +2316,14 @@ def decode_page():
|
||||
cleanup_temp_files()
|
||||
|
||||
filename = decode_result.filename or "decoded_file"
|
||||
temp_storage.save_temp_file(file_id, decode_result.file_data, {
|
||||
"filename": filename,
|
||||
"mime_type": decode_result.mime_type,
|
||||
})
|
||||
temp_storage.save_temp_file(
|
||||
file_id,
|
||||
decode_result.file_data,
|
||||
{
|
||||
"filename": filename,
|
||||
"mime_type": decode_result.mime_type,
|
||||
},
|
||||
)
|
||||
|
||||
return render_template(
|
||||
"decode.html",
|
||||
@@ -2101,11 +2679,12 @@ def api_tools_exif_clear():
|
||||
@login_required
|
||||
def api_tools_rotate():
|
||||
"""Rotate and/or flip an image, using lossless jpegtran for JPEGs."""
|
||||
from PIL import Image
|
||||
import shutil
|
||||
import subprocess
|
||||
import tempfile
|
||||
|
||||
from PIL import Image
|
||||
|
||||
image_file = request.files.get("image")
|
||||
if not image_file:
|
||||
return jsonify({"success": False, "error": "No image provided"}), 400
|
||||
@@ -2136,9 +2715,18 @@ def api_tools_rotate():
|
||||
output_path = tempfile.mktemp(suffix=".jpg")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jpegtran", "-rotate", str(rotation), "-copy", "all",
|
||||
"-outfile", output_path, input_path],
|
||||
capture_output=True, timeout=30
|
||||
[
|
||||
"jpegtran",
|
||||
"-rotate",
|
||||
str(rotation),
|
||||
"-copy",
|
||||
"all",
|
||||
"-outfile",
|
||||
output_path,
|
||||
input_path,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
with open(output_path, "rb") as f:
|
||||
@@ -2158,9 +2746,18 @@ def api_tools_rotate():
|
||||
output_path = tempfile.mktemp(suffix=".jpg")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jpegtran", "-flip", "horizontal", "-copy", "all",
|
||||
"-outfile", output_path, input_path],
|
||||
capture_output=True, timeout=30
|
||||
[
|
||||
"jpegtran",
|
||||
"-flip",
|
||||
"horizontal",
|
||||
"-copy",
|
||||
"all",
|
||||
"-outfile",
|
||||
output_path,
|
||||
input_path,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
with open(output_path, "rb") as f:
|
||||
@@ -2180,9 +2777,18 @@ def api_tools_rotate():
|
||||
output_path = tempfile.mktemp(suffix=".jpg")
|
||||
try:
|
||||
result = subprocess.run(
|
||||
["jpegtran", "-flip", "vertical", "-copy", "all",
|
||||
"-outfile", output_path, input_path],
|
||||
capture_output=True, timeout=30
|
||||
[
|
||||
"jpegtran",
|
||||
"-flip",
|
||||
"vertical",
|
||||
"-copy",
|
||||
"all",
|
||||
"-outfile",
|
||||
output_path,
|
||||
input_path,
|
||||
],
|
||||
capture_output=True,
|
||||
timeout=30,
|
||||
)
|
||||
if result.returncode == 0:
|
||||
with open(output_path, "rb") as f:
|
||||
@@ -2839,10 +3445,7 @@ def admin_settings_unlock():
|
||||
channel_status = get_channel_status()
|
||||
channel_key = channel_status.get("key") if channel_status["configured"] else ""
|
||||
|
||||
return jsonify({
|
||||
"success": True,
|
||||
"channel_key": channel_key
|
||||
})
|
||||
return jsonify({"success": True, "channel_key": channel_key})
|
||||
|
||||
|
||||
@app.route("/admin/users")
|
||||
@@ -2976,6 +3579,7 @@ if __name__ == "__main__":
|
||||
ssl_context = None
|
||||
if app.config.get("HTTPS_ENABLED", False):
|
||||
import socket
|
||||
|
||||
hostname = os.environ.get("STEGASOO_HOSTNAME") or socket.gethostname()
|
||||
try:
|
||||
cert_path, key_path = ensure_certs(base_dir, hostname)
|
||||
|
||||
Reference in New Issue
Block a user