From eb16eb1db21037d73ed89c588bfb5dad7641449d Mon Sep 17 00:00:00 2001 From: "Aaron D. Lee" Date: Tue, 6 Jan 2026 21:31:11 -0500 Subject: [PATCH] v4.1.5: Accordion UI, webcam QR scanning, Pi image fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Encode/Decode UI: - New accordion layout with 3 steps (encode) / 2 steps (decode) - Gold step numbers with checkmarks on completion - Dynamic right-aligned summaries as fields are filled - Subtle gradient highlight on active accordion step Webcam QR Scanning: - Camera button for RSA key QR codes on encode/decode pages - Camera button for channel key scanning - 3-2-1 countdown capture for dense QR codes - Proper scanner stop/restart on retry - Backend decompression for STEGASOO-Z: compressed keys RSA Key Print: - Removed identifying text from QR print output - Now prints plain QR code for discretion Pi Image Script: - Fixed 16GB resize to detect expand vs shrink - Fresh images now properly EXPAND to 16GB - Already-expanded images properly SHRINK to 16GB UI Polish: - Removed PIN helper text for compactness - Fixed QR drop zone centering - Fixed decode page element IDs for JS 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 --- PLAN-4.1.5.md | 41 + frontends/web/app.py | 208 ++++- frontends/web/static/js/generate.js | 12 +- frontends/web/static/js/stegasoo.js | 483 +++++++++++- frontends/web/stego_worker.py | 23 + frontends/web/subprocess_stego.py | 4 + frontends/web/templates/account.html | 35 +- frontends/web/templates/base.html | 2 + frontends/web/templates/decode.html | 725 ++++++++--------- frontends/web/templates/encode.html | 1080 ++++++++++++-------------- rpi/flash-stock-img.sh | 75 +- rpi/pull-image.sh | 270 ++----- 12 files changed, 1780 insertions(+), 1178 deletions(-) diff --git a/PLAN-4.1.5.md b/PLAN-4.1.5.md index eb7ae96..ddccfc2 100644 --- a/PLAN-4.1.5.md +++ b/PLAN-4.1.5.md @@ -95,6 +95,47 @@ src/stegasoo/dct_steganography.py --- +--- + +## Browser Webcam QR Scanning + +Add webcam-based QR code scanning for all key input fields. + +### Use Cases +- Import channel key via QR scan on account page +- Scan QR codes instead of typing long keys + +### Implementation + +**1. Add JS QR scanning library** +- Use `jsQR` or `html5-qrcode` (client-side, no server needed) +- Include via CDN in base template + +**2. Add camera button to channel key inputs** +- Account page: "Add Key" field +- Encode/decode pages: channel key selector (if manual input) + +**3. Camera modal component** +- Request camera permission +- Live video preview +- Auto-detect QR and populate input field +- Close modal on successful scan + +### Files to Modify +``` +frontends/web/templates/base.html - Add QR library CDN +frontends/web/templates/account.html - Camera button + modal +frontends/web/static/js/stegasoo.js - QR scan methods +``` + +### Testing Checklist +- [ ] Camera permission prompt works +- [ ] QR detected and input populated +- [ ] Works on mobile browsers +- [ ] Graceful fallback if no camera + +--- + ## Other 4.1.5 Ideas (if time) - [ ] Role-based permissions: admin / mod / user diff --git a/frontends/web/app.py b/frontends/web/app.py index 1730d4c..8a035e7 100644 --- a/frontends/web/app.py +++ b/frontends/web/app.py @@ -169,6 +169,7 @@ from subprocess_stego import ( from stegasoo.qr_utils import ( can_fit_in_qr, + decompress_data, detect_and_crop_qr, extract_key_from_qr, generate_qr_code, @@ -1049,12 +1050,19 @@ def encode_page(): ref_data = ref_photo.read() carrier_data = carrier.read() - # Handle RSA key - can come from .pem file or QR code image + # Handle RSA key - can come from .pem file, QR code image, or webcam-scanned PEM (v4.1.5) 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_file and rsa_key_file.filename: + if rsa_key_pem: + # Webcam-scanned PEM key (v4.1.5) - may be compressed + if rsa_key_pem.startswith("STEGASOO-Z:"): + 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() @@ -1371,6 +1379,82 @@ def encode_cleanup(file_id): # ============================================================================ +def _run_decode_job(job_id: str, decode_params: dict) -> None: + """Background thread function for async decode.""" + progress_file = get_progress_file_path(job_id) + + try: + _store_job(job_id, {"status": "running", "created": time.time()}) + + # Run decode with progress file + decode_result = subprocess_stego.decode( + 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", "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 "Decoding failed", + "error_type": decode_result.error_type, + "created": time.time(), + }, + ) + return + + # Store result based on type + 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, + { + "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) + + @app.route("/decode", methods=["GET", "POST"]) @login_required def decode_page(): @@ -1414,12 +1498,19 @@ def decode_page(): ref_data = ref_photo.read() stego_data = stego_image.read() - # Handle RSA key - can come from .pem file or QR code image + # Handle RSA key - can come from .pem file, QR code image, or webcam-scanned PEM (v4.1.5) 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_file and rsa_key_file.filename: + if rsa_key_pem: + # Webcam-scanned PEM key (v4.1.5) - may be compressed + if rsa_key_pem.startswith("STEGASOO-Z:"): + 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() @@ -1454,6 +1545,29 @@ def decode_page(): flash(result.error_message, "error") 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" + + # Build decode params + 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, + } + + # ASYNC MODE: Start background job and return JSON + if is_async: + job_id = generate_job_id() + _store_job(job_id, {"status": "pending", "created": time.time()}) + _executor.submit(_run_decode_job, job_id, decode_params) + return jsonify({"job_id": job_id, "status": "pending"}) + + # SYNC MODE: Run inline (original behavior) # v4.0.0: Include channel_key parameter # Use subprocess-isolated decode to prevent crashes decode_result = subprocess_stego.decode( @@ -1559,6 +1673,92 @@ def decode_download(file_id): ) +# ============================================================================ +# DECODE PROGRESS ENDPOINTS (v4.1.5) +# ============================================================================ + + +@app.route("/decode/status/") +@login_required +def decode_status(job_id): + """Get the status of an async decode job.""" + job = _get_job(job_id) + if not job: + return jsonify({"error": "Job not found"}), 404 + + response = {"status": job.get("status", "unknown")} + + if job["status"] == "complete": + response["is_file"] = job.get("is_file", False) + if job.get("is_file"): + response["file_id"] = job.get("file_id") + response["filename"] = job.get("filename") + response["file_size"] = job.get("file_size") + response["mime_type"] = job.get("mime_type") + else: + response["message"] = job.get("message") + elif job["status"] == "error": + response["error"] = job.get("error", "Unknown error") + response["error_type"] = job.get("error_type") + + return jsonify(response) + + +@app.route("/decode/progress/") +@login_required +def decode_progress(job_id): + """Get the progress of an async decode job.""" + progress = read_progress(job_id) + if progress: + return jsonify(progress) + + # No progress file yet - check job status + job = _get_job(job_id) + if not job: + return jsonify({"error": "Job not found"}), 404 + + if job["status"] == "complete": + return jsonify({"percent": 100, "phase": "complete"}) + elif job["status"] == "error": + return jsonify({"percent": 0, "phase": "error", "error": job.get("error")}) + elif job["status"] == "pending": + return jsonify({"percent": 0, "phase": "starting"}) + + # Running but no progress file yet + return jsonify({"percent": 5, "phase": "reading"}) + + +@app.route("/decode/result/") +@login_required +def decode_result(job_id): + """Get the result page for an async decode job.""" + job = _get_job(job_id) + if not job: + flash("Job not found or expired.", "error") + return redirect(url_for("decode_page")) + + if job["status"] != "complete": + flash("Decode not complete.", "error") + return redirect(url_for("decode_page")) + + if job.get("is_file"): + return render_template( + "decode.html", + decoded_file=True, + file_id=job.get("file_id"), + filename=job.get("filename"), + file_size=format_size(job.get("file_size", 0)), + mime_type=job.get("mime_type"), + has_qrcode_read=HAS_QRCODE_READ, + ) + else: + return render_template( + "decode.html", + decoded_message=job.get("message"), + has_qrcode_read=HAS_QRCODE_READ, + ) + + @app.route("/about") def about(): from stegasoo.channel import get_channel_status diff --git a/frontends/web/static/js/generate.js b/frontends/web/static/js/generate.js index 72c0d11..1e737a4 100644 --- a/frontends/web/static/js/generate.js +++ b/frontends/web/static/js/generate.js @@ -231,20 +231,14 @@ const StegasooGenerate = { printWindow.document.write(` - Stegasoo RSA Key QR Code + QR Code -

Stegasoo RSA Private Key

- RSA Key QR Code -
- Warning: This QR code contains your unencrypted RSA private key. - Store securely and destroy after use. -
+ QR Code + {% if is_admin %} {% endif %} + + {% endblock %} diff --git a/frontends/web/templates/encode.html b/frontends/web/templates/encode.html index e257873..2a1ef0a 100644 --- a/frontends/web/templates/encode.html +++ b/frontends/web/templates/encode.html @@ -4,11 +4,75 @@ {% block content %}
@@ -117,428 +154,326 @@
Encode Secret Message or File
-
+
- - -
-
- -
- -
- - Drop image or click to browse -
- - -
-
-
-
- -
-
-
-
-
-
- -
-
- - image.jpg -
-
- Hash Acquired - -- -
-
SHA256: ················
-
-
-
- The secret photo both parties have (NOT transmitted) -
-
- -
- -
- -
- - Drop image or click to browse -
- - -
- -
- -
-
-
-
-
-
- -
-
- - image.jpg -
-
- Carrier Loaded - -- -
-
-- × -- px
-
-
-
- The image to hide your message in (e.g., a meme) -
-
-
- - -
-
-
- - Carrier: - -
-
- DCT: - - LSB: - -
-
-
- - -
- - -
- - - - - -
-
- - -
- -
- - - - - -
-
- - -
- - -
- - 0 / 250,000 characters - - Getting long! - - - 0% -
-
- - -
- -
- -
- - Drop any file or click to browse -
Max {{ max_payload_kb }} KB
-
-
-
- Supports any file type: PDF, ZIP, documents, etc. -
-
- - - () -
-
- - -
- -
- -
-
- Your passphrase for this message -
- -
- -
- -
- SECURITY FACTORS - (provide at least one: PIN or RSA Key) -
- -
-
- - -
- - +
- - -
- - -
- -
- - -
-
- -
- - Drop QR image or click to browse -
- -
- Original - Cropped QR - -
-
- - RSA Key loaded -
-
- RSA Key - -- -
-
-
-
-
- - -
- - -
-
-
- - -
-
-
- -
- - -
-
Static 6-9 digit PIN
-
-
- -
-
- - - - - -
- {% if channel_configured and channel_fingerprint %} - - Server: {{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }} - {% endif %} -
-
-
-
- - -
-
- -
- - -
-
- Invalid format. Use: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX -
-
-
- - -
- - -
-
- - -
- -
-
- - + + +
+ +
+ - -
- -
-
- - AES-256-GCM Encryption -
-
- - Random Pixel Embedding -
-
- - Undetectable by Analysis -
-
- -
- - Limits: - Carrier image max ~24 megapixels (6000x4000). - Files max 30MB upload. - Payload max {{ max_payload_kb }} KB. -
+
+
+ + +
+
+ + AES-256-GCM +
+
+ + Random Pixels +
+
+ + Undetectable
@@ -549,7 +484,105 @@