1013 lines
40 KiB
Python
1013 lines
40 KiB
Python
#!/usr/bin/env python3
|
|
"""
|
|
Stegasoo CLI - Command-line interface for steganography operations (v3.0.1).
|
|
|
|
Usage:
|
|
stegasoo generate [OPTIONS]
|
|
stegasoo encode [OPTIONS]
|
|
stegasoo decode [OPTIONS]
|
|
stegasoo verify [OPTIONS]
|
|
stegasoo info [OPTIONS]
|
|
stegasoo compare [OPTIONS]
|
|
stegasoo modes [OPTIONS]
|
|
|
|
New in v3.0.1:
|
|
- DCT color mode: --dct-color (grayscale or color)
|
|
- DCT output format: --dct-format (png or jpeg)
|
|
"""
|
|
|
|
import sys
|
|
from pathlib import Path
|
|
from typing import Optional
|
|
|
|
import click
|
|
|
|
# Add parent to path for development
|
|
sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src'))
|
|
|
|
import stegasoo
|
|
from stegasoo import (
|
|
encode, encode_file, decode,
|
|
generate_credentials,
|
|
export_rsa_key_pem, load_rsa_key,
|
|
validate_image, calculate_capacity,
|
|
get_day_from_date, parse_date_from_filename,
|
|
DAY_NAMES, __version__,
|
|
StegasooError, DecryptionError, ExtractionError,
|
|
FilePayload,
|
|
will_fit,
|
|
strip_image_metadata,
|
|
# NEW in v3.0 - Embedding modes
|
|
EMBED_MODE_LSB,
|
|
EMBED_MODE_DCT,
|
|
EMBED_MODE_AUTO,
|
|
has_dct_support,
|
|
compare_modes,
|
|
will_fit_by_mode,
|
|
calculate_capacity_by_mode,
|
|
)
|
|
|
|
# QR Code utilities
|
|
try:
|
|
from stegasoo.qr_utils import (
|
|
extract_key_from_qr_file,
|
|
generate_qr_code,
|
|
has_qr_read, has_qr_write,
|
|
can_fit_in_qr, needs_compression,
|
|
)
|
|
HAS_QR = True
|
|
except ImportError:
|
|
HAS_QR = False
|
|
has_qr_read = lambda: False
|
|
has_qr_write = lambda: False
|
|
|
|
|
|
# ============================================================================
|
|
# CLI SETUP
|
|
# ============================================================================
|
|
|
|
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)
|
|
- Daily passphrase (something you know)
|
|
- Static PIN or RSA key (additional security)
|
|
|
|
\b
|
|
Embedding Modes (v3.0):
|
|
- LSB mode (default): Full color output, higher capacity
|
|
- DCT mode: Frequency domain, ~20% capacity, better stealth
|
|
|
|
\b
|
|
DCT Options (v3.0.1):
|
|
- Color mode: grayscale (default) or color (preserves colors)
|
|
- Output format: png (lossless) or jpeg (smaller, natural)
|
|
"""
|
|
pass
|
|
|
|
|
|
# ============================================================================
|
|
# 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=6, help='PIN length (6-9)')
|
|
@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=3, help='Words per phrase (3-12)')
|
|
@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 daily passphrases and optionally a PIN and/or RSA key.
|
|
At least one of --pin or --rsa must be enabled.
|
|
|
|
\b
|
|
Examples:
|
|
stegasoo generate
|
|
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),
|
|
words_per_phrase=words
|
|
)
|
|
|
|
if as_json:
|
|
import json
|
|
data = {
|
|
'phrases': creds.phrases,
|
|
'pin': creds.pin,
|
|
'rsa_key': creds.rsa_key_pem,
|
|
'entropy': {
|
|
'phrase': creds.phrase_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", 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("--- DAILY PHRASES ---", fg='green')
|
|
for day in DAY_NAMES:
|
|
phrase = creds.phrases[day]
|
|
click.echo(f" {day:9} | ", nl=False)
|
|
click.secho(phrase, fg='bright_white')
|
|
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" Phrase entropy: {creds.phrase_entropy} bits")
|
|
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(f" + photo entropy: 80-256 bits", dim=True)
|
|
click.echo()
|
|
|
|
except Exception as e:
|
|
raise click.ClickException(str(e))
|
|
|
|
|
|
# ============================================================================
|
|
# 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('--phrase', '-p', required=True, help='Day phrase')
|
|
@click.option('--pin', help='Static PIN')
|
|
@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file (.pem)')
|
|
@click.option('--key-qr', type=click.Path(exists=True), help='RSA key from QR code image')
|
|
@click.option('--key-password', help='RSA key password (for encrypted .pem files)')
|
|
@click.option('--output', '-o', type=click.Path(), help='Output file (default: auto-generated)')
|
|
@click.option('--date', 'date_str', help='Date override (YYYY-MM-DD)')
|
|
@click.option('--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, phrase, pin, key, key_qr,
|
|
key_password, output, date_str, 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 day phrase.
|
|
Must provide either --pin or --key/--key-qr (or both).
|
|
|
|
For text messages, use -m or -f or pipe via stdin.
|
|
For binary files, use -e/--embed-file.
|
|
RSA key can be provided as a .pem file (--key) or QR code image (--key-qr).
|
|
|
|
\b
|
|
Embedding Modes (v3.0):
|
|
--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
|
|
DCT Options (v3.0.1):
|
|
--dct-format png Lossless output (default)
|
|
--dct-format jpeg Smaller file, more natural appearance
|
|
|
|
--dct-color grayscale Convert to grayscale (default, traditional)
|
|
--dct-color color Preserve original colors (experimental)
|
|
|
|
\b
|
|
Examples:
|
|
# Text message with PIN (LSB mode, default)
|
|
stegasoo encode -r photo.jpg -c meme.png -p "apple forest" --pin 123456 -m "secret"
|
|
|
|
# DCT mode - grayscale PNG (traditional)
|
|
stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -m "secret" --mode dct
|
|
|
|
# DCT mode - color JPEG (v3.0.1)
|
|
stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -m "secret" \
|
|
--mode dct --dct-color color --dct-format jpeg
|
|
|
|
# DCT mode - color PNG (best quality + color preservation)
|
|
stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -m "secret" \
|
|
--mode dct --dct-color color --dct-format png
|
|
"""
|
|
# 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)
|
|
|
|
# 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 = f"\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)")
|
|
|
|
result = encode(
|
|
message=payload,
|
|
reference_photo=ref_photo,
|
|
carrier_image=carrier_image,
|
|
day_phrase=phrase,
|
|
pin=pin or "",
|
|
rsa_key_data=rsa_key_data,
|
|
rsa_password=effective_key_password,
|
|
date_str=date_str,
|
|
embed_mode=embed_mode,
|
|
dct_output_format=dct_output_format,
|
|
dct_color_mode=dct_color_mode,
|
|
)
|
|
|
|
# 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(f"[OK] 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}%")
|
|
click.echo(f" Date: {result.date_used}")
|
|
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('--phrase', '-p', required=True, help='Day phrase')
|
|
@click.option('--pin', help='Static PIN')
|
|
@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file (.pem)')
|
|
@click.option('--key-qr', type=click.Path(exists=True), help='RSA key from QR code image')
|
|
@click.option('--key-password', help='RSA key password (for encrypted .pem files)')
|
|
@click.option('--output', '-o', type=click.Path(), help='Save decoded content to file')
|
|
@click.option('--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, phrase, pin, key, key_qr, key_password, 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.
|
|
RSA key can be provided as a .pem file (--key) or QR code image (--key-qr).
|
|
|
|
Note: Extraction works the same regardless of whether the image was
|
|
created with color mode or grayscale mode - both use the same Y channel.
|
|
|
|
\b
|
|
Extraction Modes (v3.0):
|
|
--mode auto Auto-detect (default) - tries LSB first, then DCT
|
|
--mode lsb Only try LSB extraction
|
|
--mode dct Only try DCT extraction (requires scipy)
|
|
|
|
\b
|
|
Examples:
|
|
# Decode with PIN (auto-detect mode)
|
|
stegasoo decode -r photo.jpg -s stego.png -p "apple forest thunder" --pin 123456
|
|
|
|
# Explicitly specify DCT mode
|
|
stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 --mode dct
|
|
|
|
# Decode with RSA key file
|
|
stegasoo decode -r photo.jpg -s stego.png -p "words" -k mykey.pem
|
|
|
|
# Save output to file
|
|
stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 -o output.txt
|
|
"""
|
|
# 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"
|
|
)
|
|
|
|
# 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()
|
|
|
|
result = decode(
|
|
stego_image=stego_image,
|
|
reference_photo=ref_photo,
|
|
day_phrase=phrase,
|
|
pin=pin or "",
|
|
rsa_key_data=rsa_key_data,
|
|
rsa_password=effective_key_password,
|
|
embed_mode=embed_mode,
|
|
)
|
|
|
|
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("[OK] 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("[OK] Decoded successfully!", fg='green')
|
|
click.echo(f" Saved to: {output}")
|
|
else:
|
|
if quiet:
|
|
click.echo(result.message)
|
|
else:
|
|
click.secho("[OK] Decoded successfully!", fg='green')
|
|
click.echo()
|
|
click.echo(result.message)
|
|
|
|
except (DecryptionError, ExtractionError) as e:
|
|
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('--phrase', '-p', required=True, help='Day phrase')
|
|
@click.option('--pin', help='Static PIN')
|
|
@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file (.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('--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, phrase, pin, key, key_qr, key_password, 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.
|
|
|
|
\b
|
|
Examples:
|
|
stegasoo verify -r photo.jpg -s stego.png -p "apple forest thunder" --pin 123456
|
|
|
|
stegasoo verify -r photo.jpg -s stego.png -p "words" -k mykey.pem --json
|
|
|
|
stegasoo verify -r photo.jpg -s stego.png -p "words" --pin 123456 --mode dct
|
|
"""
|
|
# 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"
|
|
)
|
|
|
|
# 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
|
|
result = decode(
|
|
stego_image=stego_image,
|
|
reference_photo=ref_photo,
|
|
day_phrase=phrase,
|
|
pin=pin or "",
|
|
rsa_key_data=rsa_key_data,
|
|
rsa_password=effective_key_password,
|
|
embed_mode=embed_mode,
|
|
)
|
|
|
|
# Calculate payload size
|
|
if result.is_file:
|
|
payload_size = len(result.file_data) if result.file_data else 0
|
|
payload_type = "file"
|
|
payload_desc = result.filename or "unnamed file"
|
|
if result.mime_type:
|
|
payload_desc += f" ({result.mime_type})"
|
|
else:
|
|
payload_size = len(result.message.encode('utf-8')) if result.message else 0
|
|
payload_type = "text"
|
|
payload_desc = f"{payload_size} bytes"
|
|
|
|
# Get date info
|
|
date_encoded = result.date_encoded
|
|
day_name = get_day_from_date(date_encoded) if date_encoded else None
|
|
|
|
if as_json:
|
|
import json
|
|
output = {
|
|
"valid": True,
|
|
"stego_file": stego,
|
|
"payload_type": payload_type,
|
|
"payload_size": payload_size,
|
|
"date_encoded": date_encoded,
|
|
"day_encoded": day_name,
|
|
}
|
|
if result.is_file:
|
|
output["filename"] = result.filename
|
|
output["mime_type"] = result.mime_type
|
|
click.echo(json.dumps(output, indent=2))
|
|
else:
|
|
click.secho("[OK] Valid stego image", fg='green', bold=True)
|
|
click.echo(f" Payload: {payload_type} ({payload_desc})")
|
|
click.echo(f" Size: {payload_size:,} bytes")
|
|
if date_encoded:
|
|
click.echo(f" Encoded: {date_encoded} ({day_name})")
|
|
|
|
except (DecryptionError, ExtractionError) as e:
|
|
if as_json:
|
|
import json
|
|
output = {
|
|
"valid": False,
|
|
"stego_file": stego,
|
|
"error": str(e),
|
|
}
|
|
click.echo(json.dumps(output, indent=2))
|
|
sys.exit(1)
|
|
else:
|
|
click.secho("[FAIL] Verification failed", fg='red', bold=True)
|
|
click.echo(f" Error: {e}")
|
|
sys.exit(1)
|
|
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.
|
|
|
|
Displays dimensions, capacity for both LSB and DCT modes,
|
|
and attempts to detect date from filename.
|
|
"""
|
|
try:
|
|
image_data = Path(image).read_bytes()
|
|
|
|
result = validate_image(image_data, check_size=False)
|
|
if not result.is_valid:
|
|
raise click.ClickException(result.error_message)
|
|
|
|
# Get capacity comparison
|
|
comparison = compare_modes(image_data)
|
|
|
|
# Try to get date from filename
|
|
date_str = parse_date_from_filename(image)
|
|
day_name = get_day_from_date(date_str) if date_str else None
|
|
|
|
if as_json:
|
|
import json
|
|
output = {
|
|
"file": image,
|
|
"width": result.details['width'],
|
|
"height": result.details['height'],
|
|
"pixels": result.details['pixels'],
|
|
"mode": result.details['mode'],
|
|
"format": result.details['format'],
|
|
"capacity": {
|
|
"lsb": {
|
|
"bytes": comparison['lsb']['capacity_bytes'],
|
|
"kb": round(comparison['lsb']['capacity_kb'], 1),
|
|
},
|
|
"dct": {
|
|
"bytes": comparison['dct']['capacity_bytes'],
|
|
"kb": round(comparison['dct']['capacity_kb'], 1),
|
|
"available": comparison['dct']['available'],
|
|
"ratio_vs_lsb": round(comparison['dct']['ratio_vs_lsb'], 1),
|
|
"output_formats": ["png", "jpeg"],
|
|
"color_modes": ["grayscale", "color"],
|
|
},
|
|
},
|
|
}
|
|
if date_str:
|
|
output["embed_date"] = date_str
|
|
output["embed_day"] = day_name
|
|
click.echo(json.dumps(output, indent=2))
|
|
return
|
|
|
|
click.echo()
|
|
click.secho(f"Image: {image}", bold=True)
|
|
click.echo(f" Dimensions: {result.details['width']} x {result.details['height']}")
|
|
click.echo(f" Pixels: {result.details['pixels']:,}")
|
|
click.echo(f" Mode: {result.details['mode']}")
|
|
click.echo(f" Format: {result.details['format']}")
|
|
click.echo()
|
|
|
|
click.secho(" Capacity:", bold=True)
|
|
click.echo(f" LSB mode: ~{comparison['lsb']['capacity_bytes']:,} bytes ({comparison['lsb']['capacity_kb']:.1f} KB)")
|
|
|
|
dct_status = "[OK]" if comparison['dct']['available'] else "[X] (scipy not installed)"
|
|
click.echo(f" DCT mode: ~{comparison['dct']['capacity_bytes']:,} bytes ({comparison['dct']['capacity_kb']:.1f} KB) {dct_status}")
|
|
click.echo(f" DCT ratio: {comparison['dct']['ratio_vs_lsb']:.1f}% of LSB")
|
|
|
|
if comparison['dct']['available']:
|
|
click.secho(" DCT options: grayscale/color, png/jpeg", dim=True)
|
|
|
|
if date_str:
|
|
click.echo()
|
|
click.echo(f" Embed date: {date_str} ({day_name})")
|
|
|
|
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-size', '-s', type=int, help='Check if specific payload size fits')
|
|
@click.option('--json', 'as_json', is_flag=True, help='Output as JSON')
|
|
def compare(image, payload_size, as_json):
|
|
"""
|
|
Compare LSB and DCT embedding modes for an image.
|
|
|
|
Shows capacity for each mode and recommends which to use.
|
|
Optionally checks if a specific payload size would fit.
|
|
|
|
\b
|
|
Examples:
|
|
stegasoo compare carrier.png
|
|
stegasoo compare carrier.png --payload-size 50000
|
|
stegasoo compare carrier.png --json
|
|
"""
|
|
try:
|
|
image_data = Path(image).read_bytes()
|
|
|
|
comparison = compare_modes(image_data)
|
|
|
|
if as_json:
|
|
import json
|
|
output = {
|
|
"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["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, indent=2))
|
|
return
|
|
|
|
click.echo()
|
|
click.secho(f"=== Mode Comparison: {image} ===", fg='cyan', bold=True)
|
|
click.echo(f" Dimensions: {comparison['width']} x {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(f" | Status: [OK] 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(f" | Status: [OK] Available")
|
|
click.echo(f" | Formats: PNG (lossless), JPEG (smaller)")
|
|
click.echo(f" | Colors: Grayscale (default), Color (v3.0.1)")
|
|
else:
|
|
click.secho(f" | Status: [X] 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 = "[OK]" if fits_lsb else "[X]"
|
|
dct_icon = "[OK]" if fits_dct else "[X]"
|
|
lsb_color = 'green' if fits_lsb else 'red'
|
|
dct_color = 'green' if fits_dct else 'red'
|
|
|
|
click.echo(f" | LSB mode: ", nl=False)
|
|
click.secho(f"{lsb_icon} {'Fits' if fits_lsb else 'Too large'}", fg=lsb_color)
|
|
click.echo(f" | 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(" [X] 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
|
|
"""
|
|
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("[OK] 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 ===", fg='cyan', bold=True)
|
|
click.echo()
|
|
|
|
# LSB Mode
|
|
click.secho(" LSB Mode (Spatial LSB)", fg='green', bold=True)
|
|
click.echo(" Status: [OK] 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: [OK] Available")
|
|
else:
|
|
click.secho(" Status: [X] 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 (v3.0.1)
|
|
click.secho(" DCT Options (v3.0.1)", 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()
|
|
|
|
# Examples
|
|
click.secho(" Examples:", dim=True)
|
|
click.echo(" # Traditional DCT (grayscale PNG)")
|
|
click.echo(" stegasoo encode ... --mode dct")
|
|
click.echo()
|
|
click.echo(" # Color-preserving DCT with JPEG output")
|
|
click.echo(" stegasoo encode ... --mode dct --dct-color color --dct-format jpeg")
|
|
click.echo()
|
|
click.echo(" # Compare modes for an image")
|
|
click.echo(" stegasoo compare carrier.png")
|
|
click.echo()
|
|
|
|
|
|
# ============================================================================
|
|
# MAIN
|
|
# ============================================================================
|
|
|
|
def main():
|
|
"""Entry point."""
|
|
cli()
|
|
|
|
|
|
if __name__ == '__main__':
|
|
main()
|