EXIF Editor (Library → CLI → API → WebUI): - src/stegasoo/utils.py: read_image_exif(), write_image_exif() - CLI: stegasoo tools exif [--clear|--set Field=Value] - API: /api/tools/exif, /api/tools/exif/update, /api/tools/exif/clear - WebUI: EXIF Editor tab with inline editing, clear all, save/download Architectural consolidation: - Moved resolve_channel_key() to src/stegasoo/channel.py (was duplicated in 3 frontends) - Added get_channel_response_info() for consistent API/WebUI responses - Frontends now use thin wrappers that translate exceptions DCT improvements: - Added will_fit_by_mode() pre-check to WebUI encode (fail fast) - Suggests LSB mode when DCT capacity exceeded Dependencies: - Added piexif>=1.1.0 for EXIF editing 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
1523 lines
52 KiB
Python
1523 lines
52 KiB
Python
#!/usr/bin/env python3
|
||
"""
|
||
Stegasoo CLI - Command-line interface for steganography operations (v4.0.0).
|
||
|
||
CHANGES in v4.0.0:
|
||
- Added channel key support for deployment/group isolation
|
||
- Messages encoded with a channel key can only be decoded with the same key
|
||
- New `channel` command group for key management
|
||
|
||
CHANGES in v3.2.0:
|
||
- Removed date dependency from all operations
|
||
- Renamed day_phrase → passphrase
|
||
- No longer need to specify or remember encoding dates
|
||
- Default passphrase length increased to 4 words
|
||
|
||
Usage:
|
||
stegasoo generate [OPTIONS]
|
||
stegasoo encode [OPTIONS]
|
||
stegasoo decode [OPTIONS]
|
||
stegasoo verify [OPTIONS]
|
||
stegasoo info [OPTIONS]
|
||
stegasoo compare [OPTIONS]
|
||
stegasoo modes [OPTIONS]
|
||
stegasoo channel [SUBCOMMAND]
|
||
"""
|
||
|
||
import sys
|
||
from pathlib import Path
|
||
|
||
import click
|
||
|
||
# Add parent to path for development
|
||
sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src"))
|
||
|
||
from stegasoo import (
|
||
DecryptionError,
|
||
ExtractionError,
|
||
# Models
|
||
FilePayload,
|
||
# Exceptions
|
||
StegasooError,
|
||
# Utilities
|
||
__version__,
|
||
clear_channel_key,
|
||
compare_modes,
|
||
decode,
|
||
# Core operations
|
||
encode,
|
||
export_rsa_key_pem,
|
||
# Channel key functions (v4.0.0)
|
||
generate_channel_key,
|
||
# Credential generation
|
||
generate_credentials,
|
||
get_channel_status,
|
||
# Validation
|
||
get_image_info,
|
||
has_channel_key,
|
||
has_dct_support,
|
||
load_rsa_key,
|
||
set_channel_key,
|
||
validate_channel_key,
|
||
will_fit_by_mode,
|
||
)
|
||
|
||
# Import constants - try main module first, then constants submodule
|
||
try:
|
||
from stegasoo import ( # noqa: F401
|
||
EMBED_MODE_AUTO,
|
||
EMBED_MODE_DCT,
|
||
EMBED_MODE_LSB,
|
||
)
|
||
except ImportError:
|
||
pass
|
||
|
||
# Import constants that may not be in main __init__
|
||
try:
|
||
from stegasoo.constants import (
|
||
DEFAULT_PASSPHRASE_WORDS,
|
||
DEFAULT_PIN_LENGTH,
|
||
MAX_PIN_LENGTH,
|
||
MIN_PIN_LENGTH,
|
||
)
|
||
except ImportError:
|
||
# Fallback defaults if constants not available
|
||
DEFAULT_PASSPHRASE_WORDS = 4
|
||
DEFAULT_PIN_LENGTH = 6
|
||
MIN_PIN_LENGTH = 6
|
||
MAX_PIN_LENGTH = 9
|
||
|
||
# Optional: strip_image_metadata from utils
|
||
try:
|
||
from stegasoo.utils import strip_image_metadata
|
||
|
||
HAS_STRIP_METADATA = True
|
||
except ImportError:
|
||
HAS_STRIP_METADATA = False
|
||
|
||
# QR Code utilities
|
||
try:
|
||
from stegasoo.qr_utils import ( # noqa: F401
|
||
can_fit_in_qr,
|
||
extract_key_from_qr_file,
|
||
generate_qr_code,
|
||
has_qr_read,
|
||
has_qr_write,
|
||
needs_compression,
|
||
)
|
||
|
||
HAS_QR = True
|
||
except ImportError:
|
||
HAS_QR = False
|
||
|
||
def has_qr_read() -> bool:
|
||
return False
|
||
|
||
def has_qr_write() -> bool:
|
||
return False
|
||
|
||
|
||
# ============================================================================
|
||
# CLI SETUP
|
||
# ============================================================================
|
||
|
||
CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"])
|
||
|
||
|
||
@click.group(context_settings=CONTEXT_SETTINGS)
|
||
@click.version_option(__version__, "-v", "--version")
|
||
def cli():
|
||
"""
|
||
Stegasoo - Secure steganography with hybrid authentication.
|
||
|
||
Hide encrypted messages or files in images using a combination of:
|
||
|
||
\b
|
||
- Reference photo (something you have)
|
||
- Passphrase (something you know)
|
||
- Static PIN or RSA key (additional security)
|
||
- Channel key (deployment/group isolation) [v4.0.0]
|
||
|
||
\b
|
||
Version 4.0.0 Changes:
|
||
- Channel key support for group/deployment isolation
|
||
- Messages encoded with a channel key require the same key to decode
|
||
- New `stegasoo channel` command for key management
|
||
|
||
\b
|
||
Embedding Modes:
|
||
- LSB mode (default): Full color output, higher capacity
|
||
- DCT mode: Frequency domain, ~20% capacity, better stealth
|
||
|
||
\b
|
||
DCT Options:
|
||
- Color mode: grayscale (default) or color (preserves colors)
|
||
- Output format: png (lossless) or jpeg (smaller, natural)
|
||
"""
|
||
pass
|
||
|
||
|
||
# ============================================================================
|
||
# CHANNEL KEY HELPERS
|
||
# ============================================================================
|
||
|
||
|
||
def resolve_channel_key_option(
|
||
channel: str | None, channel_file: str | None, no_channel: bool
|
||
) -> str | None:
|
||
"""
|
||
Resolve channel key from CLI options.
|
||
|
||
Wrapper around library's resolve_channel_key with Click exception handling.
|
||
|
||
Returns:
|
||
None: Use server-configured key (auto mode)
|
||
"": Public mode (no channel key)
|
||
str: Explicit channel key
|
||
"""
|
||
from stegasoo.channel import resolve_channel_key
|
||
|
||
try:
|
||
return resolve_channel_key(
|
||
value=channel,
|
||
file_path=channel_file,
|
||
no_channel=no_channel,
|
||
)
|
||
except FileNotFoundError as e:
|
||
raise click.ClickException(str(e))
|
||
except ValueError as e:
|
||
raise click.ClickException(str(e))
|
||
|
||
|
||
def format_channel_status_line(quiet: bool = False) -> str | None:
|
||
"""Get a one-line status for channel key configuration."""
|
||
if quiet:
|
||
return None
|
||
|
||
status = get_channel_status()
|
||
if status["mode"] == "public":
|
||
return None
|
||
|
||
return f"Channel: {status['fingerprint']} ({status['source']})"
|
||
|
||
|
||
# ============================================================================
|
||
# GENERATE COMMAND
|
||
# ============================================================================
|
||
|
||
|
||
@cli.command()
|
||
@click.option("--pin/--no-pin", default=True, help="Generate a PIN (default: yes)")
|
||
@click.option("--rsa/--no-rsa", default=False, help="Generate an RSA key")
|
||
@click.option(
|
||
"--pin-length",
|
||
type=click.IntRange(6, 9),
|
||
default=DEFAULT_PIN_LENGTH,
|
||
help=f"PIN length (6-9, default: {DEFAULT_PIN_LENGTH})",
|
||
)
|
||
@click.option(
|
||
"--rsa-bits", type=click.Choice(["2048", "3072", "4096"]), default="2048", help="RSA key size"
|
||
)
|
||
@click.option(
|
||
"--words",
|
||
type=click.IntRange(3, 12),
|
||
default=DEFAULT_PASSPHRASE_WORDS,
|
||
help=f"Words per passphrase (default: {DEFAULT_PASSPHRASE_WORDS})",
|
||
)
|
||
@click.option("--output", "-o", type=click.Path(), help="Save RSA key to file (requires password)")
|
||
@click.option("--password", "-p", help="Password for RSA key file")
|
||
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
||
def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
|
||
"""
|
||
Generate credentials for encoding/decoding.
|
||
|
||
Creates a passphrase and optionally a PIN and/or RSA key.
|
||
At least one of --pin or --rsa must be enabled.
|
||
|
||
v3.2.0: Single passphrase (no more daily rotation!)
|
||
Default increased to 4 words for better security.
|
||
|
||
\b
|
||
Examples:
|
||
stegasoo generate
|
||
stegasoo generate --words 5
|
||
stegasoo generate --rsa --rsa-bits 4096
|
||
stegasoo generate --rsa -o mykey.pem -p "secretpassword"
|
||
stegasoo generate --no-pin --rsa
|
||
"""
|
||
if not pin and not rsa:
|
||
raise click.UsageError("Must enable at least one of --pin or --rsa")
|
||
|
||
if output and not password:
|
||
raise click.UsageError("--password is required when saving RSA key to file")
|
||
|
||
if password and len(password) < 8:
|
||
raise click.UsageError("Password must be at least 8 characters")
|
||
|
||
try:
|
||
creds = generate_credentials(
|
||
use_pin=pin,
|
||
use_rsa=rsa,
|
||
pin_length=pin_length,
|
||
rsa_bits=int(rsa_bits),
|
||
passphrase_words=words, # v3.2.0: renamed parameter
|
||
rsa_password=password if output else None,
|
||
)
|
||
|
||
if as_json:
|
||
import json
|
||
|
||
data = {
|
||
"passphrase": creds.passphrase,
|
||
"pin": creds.pin,
|
||
"rsa_key": creds.rsa_key_pem,
|
||
"entropy": {
|
||
"passphrase": creds.passphrase_entropy,
|
||
"pin": creds.pin_entropy,
|
||
"rsa": creds.rsa_entropy,
|
||
"total": creds.total_entropy,
|
||
},
|
||
}
|
||
click.echo(json.dumps(data, indent=2))
|
||
return
|
||
|
||
# Pretty output
|
||
click.echo()
|
||
click.secho("=" * 60, fg="cyan")
|
||
click.secho(" STEGASOO CREDENTIALS (v4.0.0)", fg="cyan", bold=True)
|
||
click.secho("=" * 60, fg="cyan")
|
||
click.echo()
|
||
|
||
click.secho("⚠️ MEMORIZE THESE AND CLOSE THIS WINDOW", fg="yellow", bold=True)
|
||
click.secho(" Do not screenshot or save to file!", fg="yellow")
|
||
click.echo()
|
||
|
||
if creds.pin:
|
||
click.secho("─── STATIC PIN ───", fg="green")
|
||
click.secho(f" {creds.pin}", fg="bright_yellow", bold=True)
|
||
click.echo()
|
||
|
||
click.secho("─── PASSPHRASE ───", fg="green")
|
||
click.secho(f" {creds.passphrase}", fg="bright_white", bold=True)
|
||
click.echo()
|
||
|
||
if creds.rsa_key_pem:
|
||
click.secho("─── RSA KEY ───", fg="green")
|
||
if output:
|
||
# Save to file
|
||
private_key = load_rsa_key(creds.rsa_key_pem.encode())
|
||
encrypted_pem = export_rsa_key_pem(private_key, password)
|
||
Path(output).write_bytes(encrypted_pem)
|
||
click.secho(f" Saved to: {output}", fg="bright_white")
|
||
click.secho(f" Password: {'*' * len(password)}", dim=True)
|
||
else:
|
||
click.echo(creds.rsa_key_pem)
|
||
click.echo()
|
||
|
||
click.secho("─── SECURITY ───", fg="green")
|
||
click.echo(f" Passphrase entropy: {creds.passphrase_entropy} bits ({words} words)")
|
||
if creds.pin:
|
||
click.echo(f" PIN entropy: {creds.pin_entropy} bits")
|
||
if creds.rsa_key_pem:
|
||
click.echo(f" RSA entropy: {creds.rsa_entropy} bits")
|
||
click.echo(f" Combined: {creds.total_entropy} bits")
|
||
click.secho(" + photo entropy: 80-256 bits", dim=True)
|
||
click.echo()
|
||
|
||
# Show channel key status
|
||
if has_channel_key():
|
||
status = get_channel_status()
|
||
click.secho("─── CHANNEL KEY ───", fg="magenta")
|
||
click.echo(" Status: Private mode")
|
||
click.echo(f" Fingerprint: {status['fingerprint']}")
|
||
click.secho(f" (configured via {status['source']})", dim=True)
|
||
click.echo()
|
||
|
||
click.secho("✓ v4.0.0: Use this passphrase anytime - no date needed!", fg="cyan")
|
||
click.echo()
|
||
|
||
except Exception as e:
|
||
raise click.ClickException(str(e))
|
||
|
||
|
||
# ============================================================================
|
||
# CHANNEL COMMAND GROUP (v4.0.0)
|
||
# ============================================================================
|
||
|
||
|
||
@cli.group()
|
||
def channel():
|
||
"""
|
||
Manage channel keys for deployment/group isolation.
|
||
|
||
Channel keys allow different deployments or groups to use Stegasoo
|
||
without being able to read each other's messages, even with identical
|
||
credentials.
|
||
|
||
\b
|
||
Key Storage (checked in order):
|
||
1. Environment variable: STEGASOO_CHANNEL_KEY
|
||
2. Project config: ./config/channel.key
|
||
3. User config: ~/.stegasoo/channel.key
|
||
|
||
\b
|
||
Subcommands:
|
||
generate Create a new channel key
|
||
show Display current channel key status
|
||
set Save a channel key to config file
|
||
clear Remove channel key from config
|
||
|
||
\b
|
||
Examples:
|
||
stegasoo channel generate
|
||
stegasoo channel show
|
||
stegasoo channel set XXXX-XXXX-...
|
||
stegasoo channel clear
|
||
"""
|
||
pass
|
||
|
||
|
||
@channel.command("generate")
|
||
@click.option("--save", "-s", is_flag=True, help="Save to user config (~/.stegasoo/channel.key)")
|
||
@click.option("--save-project", is_flag=True, help="Save to project config (./config/channel.key)")
|
||
@click.option("--env", "-e", is_flag=True, help="Output as environment variable export")
|
||
@click.option("--quiet", "-q", is_flag=True, help="Output only the key")
|
||
def channel_generate(save, save_project, env, quiet):
|
||
"""
|
||
Generate a new channel key.
|
||
|
||
Creates a cryptographically secure 256-bit channel key in the format:
|
||
XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
|
||
|
||
\b
|
||
Examples:
|
||
# Just display a new key
|
||
stegasoo channel generate
|
||
|
||
# Save to user config
|
||
stegasoo channel generate --save
|
||
|
||
# Output for .env file
|
||
stegasoo channel generate --env >> .env
|
||
|
||
# For scripts
|
||
KEY=$(stegasoo channel generate -q)
|
||
"""
|
||
key = generate_channel_key()
|
||
|
||
if save and save_project:
|
||
raise click.UsageError("Cannot use both --save and --save-project")
|
||
|
||
if save:
|
||
set_channel_key(key, location="user")
|
||
if not quiet:
|
||
click.secho("✓ Channel key saved to ~/.stegasoo/channel.key", fg="green")
|
||
click.echo()
|
||
|
||
if save_project:
|
||
set_channel_key(key, location="project")
|
||
if not quiet:
|
||
click.secho("✓ Channel key saved to ./config/channel.key", fg="green")
|
||
click.echo()
|
||
|
||
if env:
|
||
click.echo(f"STEGASOO_CHANNEL_KEY={key}")
|
||
elif quiet:
|
||
click.echo(key)
|
||
else:
|
||
click.echo()
|
||
click.secho("─── NEW CHANNEL KEY ───", fg="cyan", bold=True)
|
||
click.echo()
|
||
click.secho(f" {key}", fg="bright_yellow", bold=True)
|
||
click.echo()
|
||
|
||
fingerprint = f"{key[:4]}-••••-••••-••••-••••-••••-••••-{key[-4:]}"
|
||
click.echo(f" Fingerprint: {fingerprint}")
|
||
click.echo()
|
||
|
||
click.secho("Usage:", dim=True)
|
||
click.echo(" # Environment variable (recommended)")
|
||
click.echo(f" export STEGASOO_CHANNEL_KEY={key}")
|
||
click.echo()
|
||
click.echo(" # Or save to config")
|
||
click.echo(" stegasoo channel generate --save")
|
||
click.echo()
|
||
click.echo(" # Or add to .env file")
|
||
click.echo(" stegasoo channel generate --env >> .env")
|
||
click.echo()
|
||
|
||
|
||
@channel.command("show")
|
||
@click.option("--reveal", "-r", is_flag=True, help="Show full key (not just fingerprint)")
|
||
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
||
def channel_show(reveal, as_json):
|
||
"""
|
||
Display current channel key status.
|
||
|
||
Shows whether a channel key is configured and where it comes from.
|
||
By default shows only fingerprint; use --reveal to see full key.
|
||
|
||
\b
|
||
Examples:
|
||
stegasoo channel show
|
||
stegasoo channel show --reveal
|
||
stegasoo channel show --json
|
||
"""
|
||
status = get_channel_status()
|
||
|
||
if as_json:
|
||
import json
|
||
|
||
output = {
|
||
"mode": status["mode"],
|
||
"configured": status["configured"],
|
||
"fingerprint": status.get("fingerprint"),
|
||
"source": status.get("source"),
|
||
}
|
||
if reveal and status["configured"]:
|
||
output["key"] = status.get("key")
|
||
click.echo(json.dumps(output, indent=2))
|
||
return
|
||
|
||
click.echo()
|
||
click.secho("─── CHANNEL KEY STATUS ───", fg="cyan", bold=True)
|
||
click.echo()
|
||
|
||
if status["mode"] == "public":
|
||
click.secho(" Mode: PUBLIC", fg="yellow", bold=True)
|
||
click.echo(" No channel key configured.")
|
||
click.echo()
|
||
click.secho(" Messages can be read by any Stegasoo installation", dim=True)
|
||
click.secho(" with matching credentials.", dim=True)
|
||
else:
|
||
click.secho(" Mode: PRIVATE", fg="green", bold=True)
|
||
click.echo(f" Fingerprint: {status['fingerprint']}")
|
||
click.echo(f" Source: {status['source']}")
|
||
|
||
if reveal:
|
||
click.echo()
|
||
click.secho(f" Full key: {status['key']}", fg="bright_yellow")
|
||
|
||
click.echo()
|
||
click.secho(" Messages require this channel key to decode.", dim=True)
|
||
|
||
click.echo()
|
||
|
||
|
||
@channel.command("set")
|
||
@click.argument("key", required=False)
|
||
@click.option("--file", "-f", "key_file", type=click.Path(exists=True), help="Read key from file")
|
||
@click.option("--project", "-p", is_flag=True, help="Save to project config instead of user config")
|
||
def channel_set(key, key_file, project):
|
||
"""
|
||
Save a channel key to config file.
|
||
|
||
Saves to user config (~/.stegasoo/channel.key) by default,
|
||
or project config (./config/channel.key) with --project.
|
||
|
||
\b
|
||
Examples:
|
||
stegasoo channel set XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
|
||
stegasoo channel set --file channel.key
|
||
stegasoo channel set XXXX-... --project
|
||
"""
|
||
if not key and not key_file:
|
||
raise click.UsageError("Must provide KEY argument or --file option")
|
||
|
||
if key and key_file:
|
||
raise click.UsageError("Cannot use both KEY argument and --file option")
|
||
|
||
if key_file:
|
||
key = Path(key_file).read_text().strip()
|
||
|
||
if not validate_channel_key(key):
|
||
raise click.ClickException(
|
||
"Invalid channel key format.\n"
|
||
"Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX\n"
|
||
"Generate a new key with: stegasoo channel generate"
|
||
)
|
||
|
||
location = "project" if project else "user"
|
||
set_channel_key(key, location=location)
|
||
|
||
status = get_channel_status()
|
||
click.secho("✓ Channel key saved", fg="green")
|
||
click.echo(f" Location: {status['source']}")
|
||
click.echo(f" Fingerprint: {status['fingerprint']}")
|
||
|
||
|
||
@channel.command("clear")
|
||
@click.option("--project", "-p", is_flag=True, help="Clear project config instead of user config")
|
||
@click.option("--all", "clear_all", is_flag=True, help="Clear both user and project configs")
|
||
@click.option("--force", "-f", is_flag=True, help="Skip confirmation")
|
||
def channel_clear(project, clear_all, force):
|
||
"""
|
||
Remove channel key from config.
|
||
|
||
Clears user config by default. Use --project for project config,
|
||
or --all to clear both.
|
||
|
||
Note: This does not affect environment variables.
|
||
|
||
\b
|
||
Examples:
|
||
stegasoo channel clear
|
||
stegasoo channel clear --project
|
||
stegasoo channel clear --all
|
||
"""
|
||
if not force:
|
||
if clear_all:
|
||
msg = "Clear channel key from both user and project configs?"
|
||
elif project:
|
||
msg = "Clear channel key from project config (./config/channel.key)?"
|
||
else:
|
||
msg = "Clear channel key from user config (~/.stegasoo/channel.key)?"
|
||
|
||
if not click.confirm(msg):
|
||
click.echo("Cancelled.")
|
||
return
|
||
|
||
if clear_all:
|
||
clear_channel_key(location="user")
|
||
clear_channel_key(location="project")
|
||
click.secho("✓ Cleared channel key from user and project configs", fg="green")
|
||
elif project:
|
||
clear_channel_key(location="project")
|
||
click.secho("✓ Cleared channel key from project config", fg="green")
|
||
else:
|
||
clear_channel_key(location="user")
|
||
click.secho("✓ Cleared channel key from user config", fg="green")
|
||
|
||
# Show current status
|
||
status = get_channel_status()
|
||
if status["configured"]:
|
||
click.echo()
|
||
click.secho(f"Note: Channel key still active from {status['source']}", fg="yellow")
|
||
click.echo(f" Fingerprint: {status['fingerprint']}")
|
||
else:
|
||
click.echo(" Mode is now: PUBLIC")
|
||
|
||
|
||
# ============================================================================
|
||
# ENCODE COMMAND
|
||
# ============================================================================
|
||
|
||
|
||
@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="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("--passphrase", "-p", required=True, help="Passphrase")
|
||
@click.option("--pin", help="Static PIN")
|
||
@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("--channel", "channel_key", help='Channel key (or "auto" for server config)')
|
||
@click.option("--channel-file", type=click.Path(exists=True), help="Read channel key from file")
|
||
@click.option("--no-channel", is_flag=True, help="Force public mode (no channel key)")
|
||
@click.option("--output", "-o", type=click.Path(), help="Output file (default: auto-generated)")
|
||
@click.option(
|
||
"--mode",
|
||
"embed_mode",
|
||
type=click.Choice(["lsb", "dct"]),
|
||
default="lsb",
|
||
help="Embedding mode: lsb (default, color) or dct (requires scipy)",
|
||
)
|
||
@click.option(
|
||
"--dct-format",
|
||
"dct_output_format",
|
||
type=click.Choice(["png", "jpeg"]),
|
||
default="png",
|
||
help="DCT output format: png (lossless, default) or jpeg (smaller)",
|
||
)
|
||
@click.option(
|
||
"--dct-color",
|
||
"dct_color_mode",
|
||
type=click.Choice(["grayscale", "color"]),
|
||
default="grayscale",
|
||
help="DCT color mode: grayscale (default) or color (preserves original colors)",
|
||
)
|
||
@click.option("--quiet", "-q", is_flag=True, help="Suppress output except errors")
|
||
def encode_cmd(
|
||
ref,
|
||
carrier,
|
||
message,
|
||
message_file,
|
||
embed_file,
|
||
passphrase,
|
||
pin,
|
||
key,
|
||
key_qr,
|
||
key_password,
|
||
channel_key,
|
||
channel_file,
|
||
no_channel,
|
||
output,
|
||
embed_mode,
|
||
dct_output_format,
|
||
dct_color_mode,
|
||
quiet,
|
||
):
|
||
"""
|
||
Encode a secret message or file into an image.
|
||
|
||
Requires a reference photo, carrier image, and passphrase.
|
||
Must provide either --pin or --key/--key-qr (or both).
|
||
|
||
v4.0.0: Channel key support for deployment isolation.
|
||
v3.2.0: No --date parameter needed! Encode and decode anytime.
|
||
|
||
\b
|
||
Channel Key Options:
|
||
(no option) Use server-configured key (auto mode)
|
||
--channel KEY Use explicit channel key
|
||
--channel-file F Read channel key from file
|
||
--no-channel Force public mode (no isolation)
|
||
|
||
\b
|
||
Embedding Modes:
|
||
--mode lsb Spatial LSB embedding (default)
|
||
- Full color output (PNG/BMP)
|
||
- Higher capacity (~375 KB/megapixel)
|
||
|
||
--mode dct DCT domain embedding (requires scipy)
|
||
- Configurable color/grayscale output
|
||
- Lower capacity (~75 KB/megapixel)
|
||
- Better resistance to visual analysis
|
||
|
||
\b
|
||
Examples:
|
||
# Text message with PIN (auto channel key)
|
||
stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder" --pin 123456 -m "secret"
|
||
|
||
# Explicit channel key
|
||
stegasoo encode -r photo.jpg -c meme.png -p "words here" --pin 123456 -m "msg" \\
|
||
--channel ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
|
||
|
||
# Public mode (no channel key)
|
||
stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -m "msg" --no-channel
|
||
"""
|
||
# Check DCT mode availability
|
||
if embed_mode == "dct" and not has_dct_support():
|
||
raise click.ClickException("DCT mode requires scipy. Install with: pip install scipy")
|
||
|
||
# Warn if DCT options used with LSB mode
|
||
if embed_mode == "lsb":
|
||
if dct_output_format != "png" or dct_color_mode != "grayscale":
|
||
if not quiet:
|
||
click.secho(
|
||
"Note: --dct-format and --dct-color only apply to DCT mode",
|
||
fg="yellow",
|
||
dim=True,
|
||
)
|
||
|
||
# Resolve channel key
|
||
try:
|
||
resolved_channel_key = resolve_channel_key_option(channel_key, channel_file, no_channel)
|
||
except click.ClickException:
|
||
raise
|
||
|
||
# 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/--key-qr (or both)")
|
||
|
||
try:
|
||
ref_photo = Path(ref).read_bytes()
|
||
carrier_image = Path(carrier).read_bytes()
|
||
|
||
# Pre-check capacity with selected mode
|
||
fit_check = will_fit_by_mode(payload, carrier_image, embed_mode=embed_mode)
|
||
if not fit_check["fits"]:
|
||
# Suggest alternative mode if it would fit
|
||
alt_mode = "lsb" if embed_mode == "dct" else "dct"
|
||
alt_check = will_fit_by_mode(payload, carrier_image, embed_mode=alt_mode)
|
||
|
||
suggestion = ""
|
||
if alt_mode == "lsb" and alt_check["fits"]:
|
||
suggestion = "\n Tip: Payload would fit in LSB mode (--mode lsb)"
|
||
|
||
raise click.ClickException(
|
||
f"Payload too large for {embed_mode.upper()} mode.\n"
|
||
f" Payload: {fit_check['payload_size']:,} bytes\n"
|
||
f" Capacity: {fit_check['capacity']:,} bytes\n"
|
||
f" Shortfall: {-fit_check['headroom']:,} bytes"
|
||
f"{suggestion}"
|
||
)
|
||
|
||
if not quiet:
|
||
mode_desc = embed_mode.upper()
|
||
if embed_mode == "dct":
|
||
mode_desc += f" ({dct_color_mode}, {dct_output_format.upper()})"
|
||
click.echo(f"Mode: {mode_desc} ({fit_check['usage_percent']:.1f}% capacity)")
|
||
|
||
# Show channel status
|
||
channel_status = format_channel_status_line()
|
||
if resolved_channel_key == "":
|
||
click.echo("Channel: PUBLIC (no isolation)")
|
||
elif resolved_channel_key:
|
||
fingerprint = f"{resolved_channel_key[:4]}-••••-...-{resolved_channel_key[-4:]}"
|
||
click.echo(f"Channel: {fingerprint} (explicit)")
|
||
elif channel_status:
|
||
click.echo(channel_status)
|
||
|
||
# v4.0.0: Include channel_key parameter
|
||
result = encode(
|
||
message=payload,
|
||
reference_photo=ref_photo,
|
||
carrier_image=carrier_image,
|
||
passphrase=passphrase,
|
||
pin=pin or "",
|
||
rsa_key_data=rsa_key_data,
|
||
rsa_password=effective_key_password,
|
||
embed_mode=embed_mode,
|
||
dct_output_format=dct_output_format,
|
||
dct_color_mode=dct_color_mode,
|
||
channel_key=resolved_channel_key,
|
||
)
|
||
|
||
# Determine output path
|
||
if output:
|
||
out_path = Path(output)
|
||
else:
|
||
out_path = Path(result.filename)
|
||
|
||
# Write output
|
||
out_path.write_bytes(result.stego_image)
|
||
|
||
if not quiet:
|
||
click.secho("✓ Encoded successfully!", fg="green")
|
||
click.echo(f" Output: {out_path}")
|
||
click.echo(f" Size: {len(result.stego_image):,} bytes")
|
||
click.echo(f" Capacity used: {result.capacity_percent:.1f}%")
|
||
if embed_mode == "dct":
|
||
color_note = "color preserved" if dct_color_mode == "color" else "grayscale"
|
||
format_note = dct_output_format.upper()
|
||
click.secho(f" DCT output: {format_note} ({color_note})", dim=True)
|
||
|
||
except StegasooError as e:
|
||
raise click.ClickException(str(e))
|
||
except click.ClickException:
|
||
raise
|
||
except Exception as e:
|
||
raise click.ClickException(f"Error: {e}")
|
||
|
||
|
||
# ============================================================================
|
||
# DECODE COMMAND
|
||
# ============================================================================
|
||
|
||
|
||
@cli.command()
|
||
@click.option("--ref", "-r", required=True, type=click.Path(exists=True), help="Reference photo")
|
||
@click.option("--stego", "-s", required=True, type=click.Path(exists=True), help="Stego image")
|
||
@click.option("--passphrase", "-p", required=True, help="Passphrase")
|
||
@click.option("--pin", help="Static PIN")
|
||
@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("--channel", "channel_key", help='Channel key (or "auto" for server config)')
|
||
@click.option("--channel-file", type=click.Path(exists=True), help="Read channel key from file")
|
||
@click.option("--no-channel", is_flag=True, help="Force public mode (no channel key)")
|
||
@click.option("--output", "-o", type=click.Path(), help="Save decoded content to file")
|
||
@click.option(
|
||
"--mode",
|
||
"embed_mode",
|
||
type=click.Choice(["auto", "lsb", "dct"]),
|
||
default="auto",
|
||
help="Extraction mode: auto (default), lsb, or dct",
|
||
)
|
||
@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,
|
||
passphrase,
|
||
pin,
|
||
key,
|
||
key_qr,
|
||
key_password,
|
||
channel_key,
|
||
channel_file,
|
||
no_channel,
|
||
output,
|
||
embed_mode,
|
||
quiet,
|
||
force,
|
||
):
|
||
"""
|
||
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.
|
||
|
||
v4.0.0: Channel key support - must match what was used for encoding.
|
||
v3.2.0: No --date parameter needed! Just use your passphrase.
|
||
|
||
\b
|
||
Channel Key Options:
|
||
(no option) Use server-configured key (auto mode)
|
||
--channel KEY Use explicit channel key
|
||
--channel-file F Read channel key from file
|
||
--no-channel Force public mode (for images encoded without channel key)
|
||
|
||
\b
|
||
Examples:
|
||
# Decode with auto channel key
|
||
stegasoo decode -r photo.jpg -s stego.png -p "apple forest thunder" --pin 123456
|
||
|
||
# Decode with explicit channel key
|
||
stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 \\
|
||
--channel ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
|
||
|
||
# Decode public image (no channel key was used)
|
||
stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 --no-channel
|
||
"""
|
||
# Check DCT mode availability
|
||
if embed_mode == "dct" and not has_dct_support():
|
||
raise click.ClickException("DCT mode requires scipy. Install with: pip install scipy")
|
||
|
||
# Resolve channel key
|
||
try:
|
||
resolved_channel_key = resolve_channel_key_option(channel_key, channel_file, no_channel)
|
||
except click.ClickException:
|
||
raise
|
||
|
||
# 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/--key-qr (or both)")
|
||
|
||
try:
|
||
ref_photo = Path(ref).read_bytes()
|
||
stego_image = Path(stego).read_bytes()
|
||
|
||
# v4.0.0: Include channel_key parameter
|
||
result = decode(
|
||
stego_image=stego_image,
|
||
reference_photo=ref_photo,
|
||
passphrase=passphrase,
|
||
pin=pin or "",
|
||
rsa_key_data=rsa_key_data,
|
||
rsa_password=effective_key_password,
|
||
embed_mode=embed_mode,
|
||
channel_key=resolved_channel_key,
|
||
)
|
||
|
||
if result.is_file:
|
||
# File content
|
||
if output:
|
||
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("✓ Decoded file successfully!", fg="green")
|
||
click.echo(f" Saved to: {out_path}")
|
||
click.echo(f" Size: {len(result.file_data):,} bytes")
|
||
if result.mime_type:
|
||
click.echo(f" Type: {result.mime_type}")
|
||
else:
|
||
# Text content
|
||
if output:
|
||
Path(output).write_text(result.message)
|
||
if not quiet:
|
||
click.secho("✓ Decoded successfully!", fg="green")
|
||
click.echo(f" Saved to: {output}")
|
||
else:
|
||
if quiet:
|
||
click.echo(result.message)
|
||
else:
|
||
click.secho("✓ Decoded successfully!", fg="green")
|
||
click.echo()
|
||
click.echo(result.message)
|
||
|
||
except (DecryptionError, ExtractionError) as e:
|
||
# Provide helpful hints for channel key mismatches
|
||
error_msg = str(e)
|
||
if "channel key" in error_msg.lower():
|
||
raise click.ClickException(error_msg)
|
||
raise click.ClickException(f"Decryption failed: {e}")
|
||
except StegasooError as e:
|
||
raise click.ClickException(str(e))
|
||
except Exception as e:
|
||
raise click.ClickException(f"Error: {e}")
|
||
|
||
|
||
# ============================================================================
|
||
# VERIFY COMMAND
|
||
# ============================================================================
|
||
|
||
|
||
@cli.command()
|
||
@click.option("--ref", "-r", required=True, type=click.Path(exists=True), help="Reference photo")
|
||
@click.option("--stego", "-s", required=True, type=click.Path(exists=True), help="Stego image")
|
||
@click.option("--passphrase", "-p", required=True, help="Passphrase")
|
||
@click.option("--pin", help="Static PIN")
|
||
@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("--channel", "channel_key", help='Channel key (or "auto" for server config)')
|
||
@click.option("--channel-file", type=click.Path(exists=True), help="Read channel key from file")
|
||
@click.option("--no-channel", is_flag=True, help="Force public mode (no channel key)")
|
||
@click.option(
|
||
"--mode",
|
||
"embed_mode",
|
||
type=click.Choice(["auto", "lsb", "dct"]),
|
||
default="auto",
|
||
help="Extraction mode: auto (default), lsb, or dct",
|
||
)
|
||
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
||
def verify(
|
||
ref,
|
||
stego,
|
||
passphrase,
|
||
pin,
|
||
key,
|
||
key_qr,
|
||
key_password,
|
||
channel_key,
|
||
channel_file,
|
||
no_channel,
|
||
embed_mode,
|
||
as_json,
|
||
):
|
||
"""
|
||
Verify that a stego image can be decoded without extracting the message.
|
||
|
||
Quick check to validate credentials are correct and data is intact.
|
||
Does NOT output the actual message content.
|
||
|
||
v4.0.0: Also verifies channel key matches.
|
||
|
||
\b
|
||
Examples:
|
||
stegasoo verify -r photo.jpg -s stego.png -p "my passphrase" --pin 123456
|
||
|
||
stegasoo verify -r photo.jpg -s stego.png -p "words here" -k mykey.pem --json
|
||
|
||
stegasoo verify -r photo.jpg -s stego.png -p "test phrase" --pin 123456 --no-channel
|
||
"""
|
||
# Check DCT mode availability
|
||
if embed_mode == "dct" and not has_dct_support():
|
||
raise click.ClickException("DCT mode requires scipy. Install with: pip install scipy")
|
||
|
||
# Resolve channel key
|
||
try:
|
||
resolved_channel_key = resolve_channel_key_option(channel_key, channel_file, no_channel)
|
||
except click.ClickException:
|
||
raise
|
||
|
||
# Load key if provided
|
||
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
|
||
|
||
effective_key_password = None if rsa_key_from_qr else key_password
|
||
|
||
if not pin and not rsa_key_data:
|
||
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()
|
||
|
||
# Attempt to decode (v4.0.0: with channel_key)
|
||
result = decode(
|
||
stego_image=stego_image,
|
||
reference_photo=ref_photo,
|
||
passphrase=passphrase,
|
||
pin=pin or "",
|
||
rsa_key_data=rsa_key_data,
|
||
rsa_password=effective_key_password,
|
||
embed_mode=embed_mode,
|
||
channel_key=resolved_channel_key,
|
||
)
|
||
|
||
# Calculate payload size
|
||
if result.is_file:
|
||
payload_size = len(result.file_data)
|
||
content_type = result.mime_type or "file"
|
||
else:
|
||
payload_size = len(result.message.encode("utf-8"))
|
||
content_type = "text"
|
||
|
||
if as_json:
|
||
import json
|
||
|
||
output = {
|
||
"valid": True,
|
||
"content_type": content_type,
|
||
"payload_size": payload_size,
|
||
"filename": result.filename if result.is_file else None,
|
||
}
|
||
click.echo(json.dumps(output, indent=2))
|
||
else:
|
||
click.secho("✓ Verification successful!", fg="green")
|
||
click.echo(f" Content type: {content_type}")
|
||
click.echo(f" Payload size: {payload_size:,} bytes")
|
||
if result.is_file and result.filename:
|
||
click.echo(f" Filename: {result.filename}")
|
||
|
||
except (DecryptionError, ExtractionError) as e:
|
||
if as_json:
|
||
import json
|
||
|
||
output = {
|
||
"valid": False,
|
||
"error": str(e),
|
||
}
|
||
click.echo(json.dumps(output, indent=2))
|
||
sys.exit(1)
|
||
else:
|
||
raise click.ClickException(f"Verification failed: {e}")
|
||
except StegasooError as e:
|
||
raise click.ClickException(str(e))
|
||
except Exception as e:
|
||
raise click.ClickException(f"Error: {e}")
|
||
|
||
|
||
# ============================================================================
|
||
# INFO COMMAND
|
||
# ============================================================================
|
||
|
||
|
||
@cli.command()
|
||
@click.argument("image", type=click.Path(exists=True))
|
||
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
||
def info(image, as_json):
|
||
"""
|
||
Show information about an image file.
|
||
|
||
Displays dimensions, format, capacity estimates for different modes,
|
||
and whether the image appears suitable as a carrier.
|
||
|
||
\b
|
||
Examples:
|
||
stegasoo info photo.png
|
||
stegasoo info carrier.jpg --json
|
||
"""
|
||
try:
|
||
image_data = Path(image).read_bytes()
|
||
img_info = get_image_info(image_data)
|
||
|
||
if as_json:
|
||
import json
|
||
|
||
click.echo(json.dumps(img_info, indent=2))
|
||
return
|
||
|
||
click.echo()
|
||
click.secho(f"=== Image Info: {image} ===", fg="cyan", bold=True)
|
||
click.echo(f" Format: {img_info.get('format', 'Unknown')}")
|
||
click.echo(f" Dimensions: {img_info.get('width', '?')} × {img_info.get('height', '?')}")
|
||
click.echo(f" Mode: {img_info.get('mode', '?')}")
|
||
click.echo(f" Size: {len(image_data):,} bytes")
|
||
|
||
if "lsb_capacity" in img_info:
|
||
click.echo()
|
||
click.secho(" Capacity Estimates:", fg="green")
|
||
click.echo(f" LSB mode: {img_info['lsb_capacity']:,} bytes")
|
||
if "dct_capacity" in img_info:
|
||
click.echo(f" DCT mode: {img_info['dct_capacity']:,} bytes")
|
||
|
||
click.echo()
|
||
|
||
except Exception as e:
|
||
raise click.ClickException(str(e))
|
||
|
||
|
||
# ============================================================================
|
||
# COMPARE COMMAND
|
||
# ============================================================================
|
||
|
||
|
||
@cli.command()
|
||
@click.argument("image", type=click.Path(exists=True))
|
||
@click.option("--payload", "-p", type=click.Path(exists=True), help="Check if this file would fit")
|
||
@click.option("--size", "-s", type=int, help="Check if this many bytes would fit")
|
||
@click.option("--json", "as_json", is_flag=True, help="Output as JSON")
|
||
def compare(image, payload, size, as_json):
|
||
"""
|
||
Compare embedding mode capacities for an image.
|
||
|
||
Shows LSB vs DCT capacity and helps choose the right mode.
|
||
Optionally checks if a specific payload would fit.
|
||
|
||
\b
|
||
Examples:
|
||
stegasoo compare carrier.png
|
||
stegasoo compare carrier.png --payload secret.pdf
|
||
stegasoo compare carrier.png --size 50000
|
||
"""
|
||
try:
|
||
image_data = Path(image).read_bytes()
|
||
|
||
# Get payload size if provided
|
||
payload_size = None
|
||
if payload:
|
||
payload_size = len(Path(payload).read_bytes())
|
||
elif size:
|
||
payload_size = size
|
||
|
||
comparison = compare_modes(image_data)
|
||
|
||
if as_json:
|
||
import json
|
||
|
||
output_data = {
|
||
"file": image,
|
||
"width": comparison["width"],
|
||
"height": comparison["height"],
|
||
"modes": {
|
||
"lsb": {
|
||
"capacity_bytes": comparison["lsb"]["capacity_bytes"],
|
||
"capacity_kb": round(comparison["lsb"]["capacity_kb"], 1),
|
||
"available": True,
|
||
"output_format": comparison["lsb"]["output"],
|
||
},
|
||
"dct": {
|
||
"capacity_bytes": comparison["dct"]["capacity_bytes"],
|
||
"capacity_kb": round(comparison["dct"]["capacity_kb"], 1),
|
||
"available": comparison["dct"]["available"],
|
||
"output_formats": ["png", "jpeg"],
|
||
"color_modes": ["grayscale", "color"],
|
||
"ratio_vs_lsb_percent": round(comparison["dct"]["ratio_vs_lsb"], 1),
|
||
},
|
||
},
|
||
}
|
||
|
||
if payload_size:
|
||
output_data["payload_check"] = {
|
||
"size_bytes": payload_size,
|
||
"fits_lsb": payload_size <= comparison["lsb"]["capacity_bytes"],
|
||
"fits_dct": payload_size <= comparison["dct"]["capacity_bytes"],
|
||
}
|
||
|
||
click.echo(json.dumps(output_data, indent=2))
|
||
return
|
||
|
||
click.echo()
|
||
click.secho(f"=== Mode Comparison: {image} ===", fg="cyan", bold=True)
|
||
click.echo(f" Dimensions: {comparison['width']} × {comparison['height']}")
|
||
click.echo()
|
||
|
||
# LSB mode
|
||
click.secho(" ┌─── LSB Mode ───", fg="green")
|
||
click.echo(
|
||
f" │ Capacity: {comparison['lsb']['capacity_bytes']:,} bytes ({comparison['lsb']['capacity_kb']:.1f} KB)"
|
||
)
|
||
click.echo(f" │ Output: {comparison['lsb']['output']}")
|
||
click.echo(" │ Status: ✓ Available")
|
||
click.echo(" │")
|
||
|
||
# DCT mode
|
||
click.secho(" ├─── DCT Mode ───", fg="blue")
|
||
click.echo(
|
||
f" │ Capacity: {comparison['dct']['capacity_bytes']:,} bytes ({comparison['dct']['capacity_kb']:.1f} KB)"
|
||
)
|
||
click.echo(f" │ Ratio: {comparison['dct']['ratio_vs_lsb']:.1f}% of LSB capacity")
|
||
if comparison["dct"]["available"]:
|
||
click.echo(" │ Status: ✓ Available")
|
||
click.echo(" │ Formats: PNG (lossless), JPEG (smaller)")
|
||
click.echo(" │ Colors: Grayscale (default), Color")
|
||
else:
|
||
click.secho(" │ Status: ✗ Requires scipy (pip install scipy)", fg="yellow")
|
||
click.echo(" │")
|
||
|
||
# Payload check
|
||
if payload_size:
|
||
click.secho(" ├─── Payload Check ───", fg="magenta")
|
||
click.echo(f" │ Size: {payload_size:,} bytes")
|
||
|
||
fits_lsb = payload_size <= comparison["lsb"]["capacity_bytes"]
|
||
fits_dct = payload_size <= comparison["dct"]["capacity_bytes"]
|
||
|
||
lsb_icon = "✓" if fits_lsb else "✗"
|
||
dct_icon = "✓" if fits_dct else "✗"
|
||
lsb_color = "green" if fits_lsb else "red"
|
||
dct_color = "green" if fits_dct else "red"
|
||
|
||
click.echo(" │ LSB mode: ", nl=False)
|
||
click.secho(f"{lsb_icon} {'Fits' if fits_lsb else 'Too large'}", fg=lsb_color)
|
||
click.echo(" │ DCT mode: ", nl=False)
|
||
click.secho(f"{dct_icon} {'Fits' if fits_dct else 'Too large'}", fg=dct_color)
|
||
click.echo(" │")
|
||
|
||
# Recommendation
|
||
click.secho(" └─── Recommendation ───", fg="yellow")
|
||
if not comparison["dct"]["available"]:
|
||
click.echo(" Use LSB mode (DCT unavailable)")
|
||
elif payload_size:
|
||
if fits_dct:
|
||
click.echo(" DCT mode for better stealth (payload fits both modes)")
|
||
click.echo(" Use --dct-color color to preserve original colors")
|
||
elif fits_lsb:
|
||
click.echo(" LSB mode (payload too large for DCT)")
|
||
else:
|
||
click.secho(" ✗ Payload too large for both modes!", fg="red")
|
||
else:
|
||
click.echo(" LSB for larger payloads, DCT for better stealth")
|
||
click.echo(" DCT supports color output with --dct-color color")
|
||
|
||
click.echo()
|
||
|
||
except Exception as e:
|
||
raise click.ClickException(str(e))
|
||
|
||
|
||
# ============================================================================
|
||
# STRIP-METADATA COMMAND
|
||
# ============================================================================
|
||
|
||
|
||
@cli.command("strip-metadata")
|
||
@click.argument("image", type=click.Path(exists=True))
|
||
@click.option("--output", "-o", type=click.Path(), help="Output file (default: overwrites input)")
|
||
@click.option(
|
||
"--format",
|
||
"-f",
|
||
"output_format",
|
||
type=click.Choice(["PNG", "BMP"]),
|
||
default="PNG",
|
||
help="Output format",
|
||
)
|
||
@click.option("--quiet", "-q", is_flag=True, help="Suppress output")
|
||
def strip_metadata_cmd(image, output, output_format, quiet):
|
||
"""
|
||
Remove all metadata (EXIF, GPS, etc.) from an image.
|
||
|
||
Creates a clean image with only pixel data - no camera info,
|
||
location data, timestamps, or other potentially sensitive metadata.
|
||
|
||
\b
|
||
Examples:
|
||
stegasoo strip-metadata photo.jpg -o clean.png
|
||
stegasoo strip-metadata photo.jpg # Overwrites as PNG
|
||
"""
|
||
if not HAS_STRIP_METADATA:
|
||
raise click.ClickException("strip_image_metadata not available")
|
||
|
||
try:
|
||
image_data = Path(image).read_bytes()
|
||
original_size = len(image_data)
|
||
|
||
clean_data = strip_image_metadata(image_data, output_format)
|
||
|
||
if output:
|
||
out_path = Path(output)
|
||
else:
|
||
# Replace extension with output format
|
||
out_path = Path(image).with_suffix(f".{output_format.lower()}")
|
||
|
||
out_path.write_bytes(clean_data)
|
||
|
||
if not quiet:
|
||
click.secho("✓ Metadata stripped", fg="green")
|
||
click.echo(f" Input: {image} ({original_size:,} bytes)")
|
||
click.echo(f" Output: {out_path} ({len(clean_data):,} bytes)")
|
||
|
||
except Exception as e:
|
||
raise click.ClickException(str(e))
|
||
|
||
|
||
# ============================================================================
|
||
# MODES COMMAND
|
||
# ============================================================================
|
||
|
||
|
||
@cli.command()
|
||
def modes():
|
||
"""
|
||
Show available embedding modes and their status.
|
||
|
||
Displays which modes are available and their characteristics.
|
||
"""
|
||
click.echo()
|
||
click.secho("=== Stegasoo Embedding Modes (v4.0.0) ===", fg="cyan", bold=True)
|
||
click.echo()
|
||
|
||
# LSB Mode
|
||
click.secho(" LSB Mode (Spatial LSB)", fg="green", bold=True)
|
||
click.echo(" Status: ✓ Always available")
|
||
click.echo(" Output: PNG/BMP (full color)")
|
||
click.echo(" Capacity: ~375 KB per megapixel")
|
||
click.echo(" Use case: Larger payloads, color preservation")
|
||
click.echo(" CLI flag: --mode lsb (default)")
|
||
click.echo()
|
||
|
||
# DCT Mode
|
||
click.secho(" DCT Mode (Frequency Domain)", fg="blue", bold=True)
|
||
if has_dct_support():
|
||
click.echo(" Status: ✓ Available")
|
||
else:
|
||
click.secho(" Status: ✗ Requires scipy", fg="yellow")
|
||
click.echo(" Install: pip install scipy")
|
||
click.echo(" Capacity: ~75 KB per megapixel (~20% of LSB)")
|
||
click.echo(" Use case: Better stealth, frequency domain hiding")
|
||
click.echo(" CLI flag: --mode dct")
|
||
click.echo()
|
||
|
||
# DCT Options
|
||
click.secho(" DCT Options", fg="magenta", bold=True)
|
||
click.echo(" Output format:")
|
||
click.echo(" --dct-format png Lossless, larger file (default)")
|
||
click.echo(" --dct-format jpeg Lossy, smaller, more natural")
|
||
click.echo()
|
||
click.echo(" Color mode:")
|
||
click.echo(" --dct-color grayscale Traditional DCT (default)")
|
||
click.echo(" --dct-color color Preserves original colors")
|
||
click.echo()
|
||
|
||
# Channel Key Status (v4.0.0)
|
||
click.secho(" Channel Key (v4.0.0)", fg="cyan", bold=True)
|
||
status = get_channel_status()
|
||
if status["mode"] == "public":
|
||
click.echo(" Status: PUBLIC (no key configured)")
|
||
click.echo(" Effect: Messages readable by any installation")
|
||
else:
|
||
click.echo(" Status: PRIVATE")
|
||
click.echo(f" Fingerprint: {status['fingerprint']}")
|
||
click.echo(f" Source: {status['source']}")
|
||
click.echo(" Effect: Messages isolated to this channel")
|
||
click.echo()
|
||
click.echo(" CLI flags:")
|
||
click.echo(" --channel KEY Use explicit channel key")
|
||
click.echo(" --channel-file F Read key from file")
|
||
click.echo(" --no-channel Force public mode")
|
||
click.echo()
|
||
|
||
# v4.0.0 Changes
|
||
click.secho(" v4.0.0 Changes:", fg="cyan", bold=True)
|
||
click.echo(" ✓ Channel key support for deployment isolation")
|
||
click.echo(" ✓ New `stegasoo channel` command group")
|
||
click.echo(" ✓ Messages encoded with channel key require same key to decode")
|
||
click.echo()
|
||
|
||
# Examples
|
||
click.secho(" Examples:", dim=True)
|
||
click.echo(" # Generate channel key")
|
||
click.echo(" stegasoo channel generate --save")
|
||
click.echo()
|
||
click.echo(" # Encode with channel isolation")
|
||
click.echo(" stegasoo encode ... --channel XXXX-XXXX-...")
|
||
click.echo()
|
||
click.echo(" # Decode public message (no channel key)")
|
||
click.echo(" stegasoo decode ... --no-channel")
|
||
click.echo()
|
||
|
||
|
||
# ============================================================================
|
||
# MAIN
|
||
# ============================================================================
|
||
|
||
|
||
def main():
|
||
"""Entry point."""
|
||
cli()
|
||
|
||
|
||
if __name__ == "__main__":
|
||
main()
|