diff --git a/PLAN-4.1.0.md b/PLAN-4.1.0.md
index af75e35..0ecaf41 100644
--- a/PLAN-4.1.0.md
+++ b/PLAN-4.1.0.md
@@ -426,6 +426,23 @@ Or simpler: detect on startup, update schema automatically (current pattern).
## Progress
- [x] Multi-User Support (commit 7b33501)
-- [ ] Channel Key QR (Web UI)
+- [x] Channel Key QR (Web UI) - added QR generator on About page
- [x] CLI Channel Commands
-- [ ] Advanced Tools
+- [x] Saved Channel Keys (Web UI) - users can save/manage channel keys
+- [ ] Advanced Tools (in progress)
+
+---
+
+## Action Item: Architectural Review
+
+Review other modules for consistency with the Library → CLI → API → WebUI pattern:
+
+| Module | Library | CLI | API | WebUI | Notes |
+|--------|---------|-----|-----|-------|-------|
+| encode | ✓ | ✓ | ✓ | ✓ | Review for consistency |
+| decode | ✓ | ✓ | ✓ | ✓ | Review for consistency |
+| channel | ✓ | ✓ | - | ✓ | Needs API layer? |
+| tools | ✓ | WIP | ✓ | WIP | Building now |
+| generate | ✓ | ? | - | ✓ | CLI for credential gen? |
+
+Priority order: Developer/CLI → API integrator → WebUI end-user
diff --git a/frontends/web/app.py b/frontends/web/app.py
index 9e450b9..7b18261 100644
--- a/frontends/web/app.py
+++ b/frontends/web/app.py
@@ -1288,6 +1288,88 @@ def about():
return render_template("about.html", has_argon2=has_argon2(), has_qrcode_read=HAS_QRCODE_READ)
+# ============================================================================
+# TOOLS ROUTES (v4.1.0)
+# ============================================================================
+
+
+@app.route("/tools")
+@login_required
+def tools():
+ """Advanced tools page."""
+ return render_template("tools.html", has_dct=has_dct_support())
+
+
+@app.route("/api/tools/capacity", methods=["POST"])
+@login_required
+def api_tools_capacity():
+ """Calculate image capacity for steganography."""
+ from stegasoo.dct_steganography import estimate_capacity_comparison
+
+ carrier = request.files.get("image")
+ if not carrier:
+ return jsonify({"success": False, "error": "No image provided"}), 400
+
+ try:
+ image_data = carrier.read()
+ result = estimate_capacity_comparison(image_data)
+ result["success"] = True
+ result["filename"] = carrier.filename
+ result["megapixels"] = round((result["width"] * result["height"]) / 1_000_000, 2)
+ return jsonify(result)
+ except Exception as e:
+ return jsonify({"success": False, "error": str(e)}), 400
+
+
+@app.route("/api/tools/strip-metadata", methods=["POST"])
+@login_required
+def api_tools_strip_metadata():
+ """Strip EXIF/metadata from image."""
+ import io
+
+ from stegasoo.utils import strip_image_metadata
+
+ image_file = request.files.get("image")
+ if not image_file:
+ return jsonify({"success": False, "error": "No image provided"}), 400
+
+ try:
+ image_data = image_file.read()
+ clean_data = strip_image_metadata(image_data, output_format="PNG")
+
+ buffer = io.BytesIO(clean_data)
+ filename = image_file.filename.rsplit(".", 1)[0] + "_clean.png"
+
+ return send_file(
+ buffer,
+ mimetype="image/png",
+ as_attachment=True,
+ download_name=filename
+ )
+ except Exception as e:
+ return jsonify({"success": False, "error": str(e)}), 400
+
+
+@app.route("/api/tools/peek", methods=["POST"])
+@login_required
+def api_tools_peek():
+ """Check if image contains Stegasoo header."""
+ from stegasoo.steganography import peek_image
+
+ image_file = request.files.get("image")
+ if not image_file:
+ return jsonify({"success": False, "error": "No image provided"}), 400
+
+ try:
+ image_data = image_file.read()
+ result = peek_image(image_data)
+ result["success"] = True
+ result["filename"] = image_file.filename
+ return jsonify(result)
+ except Exception as e:
+ return jsonify({"success": False, "error": str(e)}), 400
+
+
# Add these two test routes anywhere in app.py after the app = Flask(...) line:
diff --git a/frontends/web/templates/base.html b/frontends/web/templates/base.html
index 5054bf0..cc4b4cb 100644
--- a/frontends/web/templates/base.html
+++ b/frontends/web/templates/base.html
@@ -38,6 +38,9 @@
About
+
+ Tools
+
{% if auth_enabled %}
{% if is_authenticated %}
diff --git a/frontends/web/templates/tools.html b/frontends/web/templates/tools.html
new file mode 100644
index 0000000..0944b19
--- /dev/null
+++ b/frontends/web/templates/tools.html
@@ -0,0 +1,172 @@
+{% extends "base.html" %}
+
+{% block title %}Tools - Stegasoo{% endblock %}
+
+{% block content %}
+
+
+
Image Security Toolkit
+
+
+
+ -
+
+
+ -
+
+
+ -
+
+
+
+
+
+
+
+
+
+
+
+
Check how much data can be hidden in an image.
+
+
+
+
+
+
+
+
+
+ | Image |
+ |
+
+
+ | Dimensions |
+ ( MP) |
+
+
+ | LSB Capacity |
+ |
+
+
+ | DCT Capacity |
+ |
+
+
+
+
+
+
+
+
+
+
+
+
+
Remove metadata (camera info, GPS, timestamps) from images.
+
+
+
+
+
+
+
+
+
+
+
Check if an image contains Stegasoo hidden data (without decrypting).
+
+
+
+
+
+
+
+
+ Stegasoo data detected!
+
Mode:
+
+
+
+ No Stegasoo header detected in this image.
+
+
+
+
+
+
+
+
+
+{% endblock %}
+
+{% block scripts %}
+
+{% endblock %}
diff --git a/src/stegasoo/cli.py b/src/stegasoo/cli.py
index 298245a..b1fb72d 100644
--- a/src/stegasoo/cli.py
+++ b/src/stegasoo/cli.py
@@ -782,6 +782,111 @@ def channel_clear(ctx, project, user):
click.echo("No channel key files found")
+# =============================================================================
+# TOOLS COMMANDS
+# =============================================================================
+
+
+@cli.group()
+@click.pass_context
+def tools(ctx):
+ """Image security tools."""
+ pass
+
+
+@tools.command("capacity")
+@click.argument("image", type=click.Path(exists=True))
+@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
+def tools_capacity(image, as_json):
+ """Show steganography capacity for an image.
+
+ Example:
+
+ stegasoo tools capacity photo.jpg
+ """
+ from .dct_steganography import estimate_capacity_comparison
+
+ with open(image, "rb") as f:
+ image_data = f.read()
+
+ result = estimate_capacity_comparison(image_data)
+ result["filename"] = Path(image).name
+ result["megapixels"] = round((result["width"] * result["height"]) / 1_000_000, 2)
+
+ if as_json:
+ click.echo(json.dumps(result, indent=2))
+ else:
+ click.echo(f"\n {result['filename']}")
+ click.echo(f" {'─' * 40}")
+ click.echo(f" Dimensions: {result['width']} × {result['height']}")
+ click.echo(f" Megapixels: {result['megapixels']} MP")
+ click.echo(f" {'─' * 40}")
+ click.echo(f" LSB Capacity: {result['lsb']['capacity_kb']:.1f} KB")
+ if result['dct']['available']:
+ click.echo(f" DCT Capacity: {result['dct']['capacity_kb']:.1f} KB")
+ else:
+ click.echo(" DCT Capacity: N/A (scipy required)")
+ click.echo()
+
+
+@tools.command("strip")
+@click.argument("image", type=click.Path(exists=True))
+@click.option("-o", "--output", type=click.Path(), help="Output file (default: _clean.png)")
+@click.option("--format", "fmt", type=click.Choice(["png", "bmp"]), default="png", help="Output format")
+def tools_strip(image, output, fmt):
+ """Strip EXIF/metadata from an image.
+
+ Example:
+
+ stegasoo tools strip photo.jpg
+ stegasoo tools strip photo.jpg -o clean.png
+ """
+ from .utils import strip_image_metadata
+
+ with open(image, "rb") as f:
+ image_data = f.read()
+
+ clean_data = strip_image_metadata(image_data, output_format=fmt.upper())
+
+ if not output:
+ stem = Path(image).stem
+ output = f"{stem}_clean.{fmt}"
+
+ with open(output, "wb") as f:
+ f.write(clean_data)
+
+ click.echo(f"Saved clean image to: {output}")
+
+
+@tools.command("peek")
+@click.argument("image", type=click.Path(exists=True))
+@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
+def tools_peek(image, as_json):
+ """Check if image contains Stegasoo hidden data.
+
+ Example:
+
+ stegasoo tools peek suspicious.jpg
+ """
+ from .steganography import peek_image
+
+ with open(image, "rb") as f:
+ image_data = f.read()
+
+ result = peek_image(image_data)
+ result["filename"] = Path(image).name
+
+ if as_json:
+ click.echo(json.dumps(result))
+ else:
+ if result["has_stegasoo"]:
+ click.echo(f"\n ✓ Stegasoo data detected in {result['filename']}")
+ click.echo(f" Mode: {result['mode'].upper()}")
+ else:
+ click.echo(f"\n ✗ No Stegasoo header found in {result['filename']}")
+ click.echo()
+
+
def main():
"""Entry point for CLI."""
cli(obj={})
diff --git a/src/stegasoo/steganography.py b/src/stegasoo/steganography.py
index cd2ded3..87dc666 100644
--- a/src/stegasoo/steganography.py
+++ b/src/stegasoo/steganography.py
@@ -930,3 +930,82 @@ def is_lossless_format(image_data: bytes) -> bool:
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
+
+
+def peek_image(image_data: bytes) -> dict:
+ """
+ Check if an image contains Stegasoo hidden data without decrypting.
+
+ Attempts to detect LSB and DCT headers by extracting the first few bytes
+ and looking for Stegasoo magic markers.
+
+ Args:
+ image_data: Raw image bytes
+
+ Returns:
+ dict with:
+ - has_stegasoo: bool - True if header detected
+ - mode: str or None - 'lsb', 'dct', or None
+ - confidence: str - 'high', 'low', or None
+
+ Example:
+ >>> result = peek_image(suspicious_image_bytes)
+ >>> if result['has_stegasoo']:
+ ... print(f"Found {result['mode']} data!")
+ """
+ from .constants import EMBED_MODE_DCT, EMBED_MODE_LSB
+
+ result = {"has_stegasoo": False, "mode": None, "confidence": None}
+
+ # Try LSB extraction (look for header bytes)
+ try:
+ img = Image.open(io.BytesIO(image_data))
+ pixels = list(img.getdata())
+ img.close()
+
+ # Extract first 32 bits (4 bytes) from LSB
+ extracted = []
+ for i in range(32):
+ if i < len(pixels):
+ pixel = pixels[i]
+ if isinstance(pixel, tuple):
+ extracted.append(pixel[0] & 1)
+ else:
+ extracted.append(pixel & 1)
+
+ # Convert bits to bytes
+ header_bytes = bytearray()
+ for i in range(0, len(extracted), 8):
+ byte = 0
+ for j in range(8):
+ if i + j < len(extracted):
+ byte = (byte << 1) | extracted[i + j]
+ header_bytes.append(byte)
+
+ # Check for LSB magic: \x89ST3
+ if bytes(header_bytes[:4]) == b"\x89ST3":
+ result["has_stegasoo"] = True
+ result["mode"] = EMBED_MODE_LSB
+ result["confidence"] = "high"
+ return result
+ except Exception:
+ pass
+
+ # Try DCT extraction (requires scipy/jpegio)
+ try:
+ from .dct_steganography import HAS_JPEGIO, HAS_SCIPY
+
+ if HAS_SCIPY or HAS_JPEGIO:
+ from .dct_steganography import extract_from_dct
+
+ # Extract first few bytes to check header
+ extracted = extract_from_dct(image_data, seed=b"\x00" * 32, length=4)
+ if extracted == b"\x89DCT":
+ result["has_stegasoo"] = True
+ result["mode"] = EMBED_MODE_DCT
+ result["confidence"] = "high"
+ return result
+ except Exception:
+ pass
+
+ return result
diff --git a/xx_2.jpg b/xx_2.jpg
deleted file mode 100644
index ebbfa8e..0000000
Binary files a/xx_2.jpg and /dev/null differ