Cap RSA at 3072 bits, add zstd compression for QR codes

- RSA key size capped at 3072 bits (4096 too large for QR codes)
- Added zstd compression for QR code RSA keys (better ratio than zlib)
- New prefix STEGASOO-ZS: for zstd, backward compatible with STEGASOO-Z: (zlib)
- Added zstandard dependency to web/api/compression extras
- Updated all docs, CLI options, and web UI to reflect 3072 max
- Version bump to 4.2.0

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-01-09 23:30:31 -05:00
parent 175362ce4c
commit 3fd3204552
13 changed files with 118 additions and 39 deletions

4
CLI.md
View File

@@ -164,7 +164,7 @@ stegasoo generate [OPTIONS]
| `--pin/--no-pin` | | flag | `--pin` | Generate a PIN | | `--pin/--no-pin` | | flag | `--pin` | Generate a PIN |
| `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key | | `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key |
| `--pin-length` | | 6-9 | 6 | PIN length in digits | | `--pin-length` | | 6-9 | 6 | PIN length in digits |
| `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072, 4096) | | `--rsa-bits` | | choice | 2048 | RSA key size (2048, 3072) |
| `--words` | | 3-12 | 4 | Words in passphrase | | `--words` | | 3-12 | 4 | Words in passphrase |
| `--output` | `-o` | path | | Save RSA key to file | | `--output` | `-o` | path | | Save RSA key to file |
| `--password` | `-p` | string | | Password for RSA key file | | `--password` | `-p` | string | | Password for RSA key file |
@@ -180,7 +180,7 @@ stegasoo generate
stegasoo generate --words 6 stegasoo generate --words 6
# Generate with RSA key # Generate with RSA key
stegasoo generate --rsa --rsa-bits 4096 stegasoo generate --rsa --rsa-bits 3072
# Save RSA key to encrypted file # Save RSA key to encrypted file
stegasoo generate --rsa -o mykey.pem -p "mysecretpassword" stegasoo generate --rsa -o mykey.pem -p "mysecretpassword"

View File

@@ -411,7 +411,7 @@ Create a new set of credentials for steganography operations.
| Use PIN | on/off | on | Generate a numeric PIN | | Use PIN | on/off | on | Generate a numeric PIN |
| PIN length | 6-9 | 6 | Digits in the PIN | | PIN length | 6-9 | 6 | Digits in the PIN |
| Use RSA Key | on/off | off | Generate an RSA key pair | | Use RSA Key | on/off | off | Generate an RSA key pair |
| RSA key size | 2048/3072/4096 | 2048 | Key size in bits | | RSA key size | 2048/3072 | 2048 | Key size in bits |
#### Entropy Calculator #### Entropy Calculator

View File

@@ -126,7 +126,7 @@ Quick reference for all Jinja2 templates in `frontends/web/templates/`.
- `use_pin` - checkbox - `use_pin` - checkbox
- `pin_length` - PIN digits (6-9) - `pin_length` - PIN digits (6-9)
- `use_rsa` - checkbox - `use_rsa` - checkbox
- `rsa_bits` - key size (2048/3072/4096) - `rsa_bits` - key size (2048/3072)
**Output panels:** **Output panels:**
- Passphrase display - Passphrase display

View File

@@ -236,7 +236,7 @@ def format_channel_status_line(quiet: bool = False) -> str | None:
help=f"PIN length (6-9, default: {DEFAULT_PIN_LENGTH})", help=f"PIN length (6-9, default: {DEFAULT_PIN_LENGTH})",
) )
@click.option( @click.option(
"--rsa-bits", type=click.Choice(["2048", "3072", "4096"]), default="2048", help="RSA key size" "--rsa-bits", type=click.Choice(["2048", "3072"]), default="2048", help="RSA key size"
) )
@click.option( @click.option(
"--words", "--words",
@@ -261,7 +261,7 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
Examples: Examples:
stegasoo generate stegasoo generate
stegasoo generate --words 5 stegasoo generate --words 5
stegasoo generate --rsa --rsa-bits 4096 stegasoo generate --rsa --rsa-bits 3072
stegasoo generate --rsa -o mykey.pem -p "secretpassword" stegasoo generate --rsa -o mykey.pem -p "secretpassword"
stegasoo generate --no-pin --rsa stegasoo generate --no-pin --rsa
""" """

View File

@@ -253,6 +253,7 @@ from stegasoo.qr_utils import (
detect_and_crop_qr, detect_and_crop_qr,
extract_key_from_qr, extract_key_from_qr,
generate_qr_code, generate_qr_code,
is_compressed,
) )
# Initialize subprocess wrapper (worker script must be in same directory) # Initialize subprocess wrapper (worker script must be in same directory)
@@ -1209,8 +1210,8 @@ def encode_page():
rsa_key_from_qr = False rsa_key_from_qr = False
if rsa_key_pem: if rsa_key_pem:
# Webcam-scanned PEM key (v4.1.5) - may be compressed # Webcam-scanned PEM key (v4.1.5+) - may be compressed (zlib or zstd)
if rsa_key_pem.startswith("STEGASOO-Z:"): if is_compressed(rsa_key_pem):
rsa_key_pem = decompress_data(rsa_key_pem) rsa_key_pem = decompress_data(rsa_key_pem)
rsa_key_data = rsa_key_pem.encode("utf-8") rsa_key_data = rsa_key_pem.encode("utf-8")
rsa_key_from_qr = True rsa_key_from_qr = True
@@ -1648,8 +1649,8 @@ def decode_page():
rsa_key_from_qr = False rsa_key_from_qr = False
if rsa_key_pem: if rsa_key_pem:
# Webcam-scanned PEM key (v4.1.5) - may be compressed # Webcam-scanned PEM key (v4.1.5+) - may be compressed (zlib or zstd)
if rsa_key_pem.startswith("STEGASOO-Z:"): if is_compressed(rsa_key_pem):
rsa_key_pem = decompress_data(rsa_key_pem) rsa_key_pem = decompress_data(rsa_key_pem)
rsa_key_data = rsa_key_pem.encode("utf-8") rsa_key_data = rsa_key_pem.encode("utf-8")
rsa_key_from_qr = True rsa_key_from_qr = True

View File

@@ -573,7 +573,7 @@
</tr> </tr>
<tr> <tr>
<td><i class="bi bi-shield me-2"></i>RSA keys</td> <td><i class="bi bi-shield me-2"></i>RSA keys</td>
<td><strong>2048, 3072, 4096 bit</strong></td> <td><strong>2048, 3072 bit</strong></td>
</tr> </tr>
<tr> <tr>
<td><i class="bi bi-chat-quote me-2"></i>Passphrase</td> <td><i class="bi bi-chat-quote me-2"></i>Passphrase</td>

View File

@@ -65,11 +65,7 @@
<select name="rsa_bits" class="form-select form-select-sm" id="rsaBitsSelect"> <select name="rsa_bits" class="form-select form-select-sm" id="rsaBitsSelect">
<option value="2048" selected>2048 bits (~128 bits entropy)</option> <option value="2048" selected>2048 bits (~128 bits entropy)</option>
<option value="3072">3072 bits (~128 bits entropy)</option> <option value="3072">3072 bits (~128 bits entropy)</option>
<option value="4096">4096 bits (~128 bits entropy)</option>
</select> </select>
<div class="form-text text-warning d-none" id="rsaQrWarning">
<i class="bi bi-exclamation-triangle me-1"></i>QR code unavailable for keys &gt;3072 bits
</div>
</div> </div>
</div> </div>
</div> </div>
@@ -286,12 +282,6 @@
<i class="bi bi-shield-exclamation me-1"></i> <i class="bi bi-shield-exclamation me-1"></i>
<strong>Security note:</strong> The QR code contains your unencrypted private key. <strong>Security note:</strong> The QR code contains your unencrypted private key.
Only scan in a secure environment. Consider using the password-protected download instead. Only scan in a secure environment. Consider using the password-protected download instead.
{% if rsa_bits >= 4096 %}
<br><br>
<i class="bi bi-exclamation-triangle me-1"></i>
<strong>4096-bit keys</strong> produce very dense QR codes. If scanning fails,
use the PEM text or download options instead.
{% endif %}
</div> </div>
</div> </div>
</div> </div>

View File

@@ -58,6 +58,7 @@ cli = [
] ]
compression = [ compression = [
"lz4>=4.0.0", "lz4>=4.0.0",
"zstandard>=0.22.0",
] ]
web = [ web = [
"flask>=3.0.0", "flask>=3.0.0",
@@ -65,6 +66,7 @@ web = [
"qrcode>=7.3.0", "qrcode>=7.3.0",
"pyzbar>=0.1.9", "pyzbar>=0.1.9",
"piexif>=1.1.0", "piexif>=1.1.0",
"zstandard>=0.22.0", # v4.2.0: Better compression for QR keys
# Include DCT support for web UI # Include DCT support for web UI
"numpy>=2.0.0", "numpy>=2.0.0",
"scipy>=1.10.0", "scipy>=1.10.0",
@@ -77,6 +79,7 @@ api = [
"python-multipart>=0.0.6", "python-multipart>=0.0.6",
"qrcode>=7.30", "qrcode>=7.30",
"pyzbar>=0.1.9", "pyzbar>=0.1.9",
"zstandard>=0.22.0", # v4.2.0: Better compression for QR keys
# Include DCT support for API # Include DCT support for API
"numpy>=2.0.0", "numpy>=2.0.0",
"scipy>=1.10.0", "scipy>=1.10.0",

View File

@@ -17,6 +17,14 @@ try:
except ImportError: except ImportError:
HAS_LZ4 = False HAS_LZ4 = False
# Optional ZSTD support (best ratio, fast)
try:
import zstandard as zstd
HAS_ZSTD = True
except ImportError:
HAS_ZSTD = False
class CompressionAlgorithm(IntEnum): class CompressionAlgorithm(IntEnum):
"""Supported compression algorithms.""" """Supported compression algorithms."""
@@ -24,6 +32,7 @@ class CompressionAlgorithm(IntEnum):
NONE = 0 NONE = 0
ZLIB = 1 ZLIB = 1
LZ4 = 2 LZ4 = 2
ZSTD = 3 # v4.2.0: Best ratio, fast compression
# Magic bytes for compressed payloads # Magic bytes for compressed payloads
@@ -72,6 +81,15 @@ def compress(data: bytes, algorithm: CompressionAlgorithm = CompressionAlgorithm
algorithm = CompressionAlgorithm.ZLIB algorithm = CompressionAlgorithm.ZLIB
else: else:
compressed = lz4.frame.compress(data) compressed = lz4.frame.compress(data)
elif algorithm == CompressionAlgorithm.ZSTD:
if not HAS_ZSTD:
# Fall back to zlib if ZSTD not available
compressed = zlib.compress(data, level=ZLIB_LEVEL)
algorithm = CompressionAlgorithm.ZLIB
else:
cctx = zstd.ZstdCompressor(level=19) # High compression level
compressed = cctx.compress(data)
else: else:
raise CompressionError(f"Unknown compression algorithm: {algorithm}") raise CompressionError(f"Unknown compression algorithm: {algorithm}")
@@ -123,6 +141,15 @@ def decompress(data: bytes) -> bytes:
result = lz4.frame.decompress(compressed_data) result = lz4.frame.decompress(compressed_data)
except Exception as e: except Exception as e:
raise CompressionError(f"LZ4 decompression failed: {e}") raise CompressionError(f"LZ4 decompression failed: {e}")
elif algorithm == CompressionAlgorithm.ZSTD:
if not HAS_ZSTD:
raise CompressionError("ZSTD compression used but zstandard package not installed")
try:
dctx = zstd.ZstdDecompressor()
result = dctx.decompress(compressed_data)
except Exception as e:
raise CompressionError(f"ZSTD decompression failed: {e}")
else: else:
raise CompressionError(f"Unknown compression algorithm: {algorithm}") raise CompressionError(f"Unknown compression algorithm: {algorithm}")
@@ -181,6 +208,9 @@ def estimate_compressed_size(
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL) compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
elif algorithm == CompressionAlgorithm.LZ4 and HAS_LZ4: elif algorithm == CompressionAlgorithm.LZ4 and HAS_LZ4:
compressed_sample = lz4.frame.compress(sample) compressed_sample = lz4.frame.compress(sample)
elif algorithm == CompressionAlgorithm.ZSTD and HAS_ZSTD:
cctx = zstd.ZstdCompressor(level=19)
compressed_sample = cctx.compress(sample)
else: else:
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL) compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
@@ -195,14 +225,24 @@ def get_available_algorithms() -> list[CompressionAlgorithm]:
algorithms = [CompressionAlgorithm.NONE, CompressionAlgorithm.ZLIB] algorithms = [CompressionAlgorithm.NONE, CompressionAlgorithm.ZLIB]
if HAS_LZ4: if HAS_LZ4:
algorithms.append(CompressionAlgorithm.LZ4) algorithms.append(CompressionAlgorithm.LZ4)
if HAS_ZSTD:
algorithms.append(CompressionAlgorithm.ZSTD)
return algorithms return algorithms
def get_best_algorithm() -> CompressionAlgorithm:
"""Get the best available compression algorithm (prefer ZSTD > ZLIB > LZ4)."""
if HAS_ZSTD:
return CompressionAlgorithm.ZSTD
return CompressionAlgorithm.ZLIB
def algorithm_name(algo: CompressionAlgorithm) -> str: def algorithm_name(algo: CompressionAlgorithm) -> str:
"""Get human-readable algorithm name.""" """Get human-readable algorithm name."""
names = { names = {
CompressionAlgorithm.NONE: "None", CompressionAlgorithm.NONE: "None",
CompressionAlgorithm.ZLIB: "Zlib (deflate)", CompressionAlgorithm.ZLIB: "Zlib (deflate)",
CompressionAlgorithm.LZ4: "LZ4 (fast)", CompressionAlgorithm.LZ4: "LZ4 (fast)",
CompressionAlgorithm.ZSTD: "Zstd (best)",
} }
return names.get(algo, "Unknown") return names.get(algo, "Unknown")

View File

@@ -1,9 +1,15 @@
""" """
Stegasoo Constants and Configuration (v4.0.2 - Web UI Authentication) Stegasoo Constants and Configuration (v4.2.0 - Performance & Compression)
Central location for all magic numbers, limits, and crypto parameters. Central location for all magic numbers, limits, and crypto parameters.
All version numbers, limits, and configuration values should be defined here. All version numbers, limits, and configuration values should be defined here.
CHANGES in v4.2.0:
- Added zstd compression for QR codes (better ratio than zlib)
- RSA key size capped at 3072 bits (4096 too large for QR codes)
- Progress bar improvements for encode/decode operations
- File auto-expire increased to 10 minutes
CHANGES in v4.0.2: CHANGES in v4.0.2:
- Added Web UI authentication with SQLite3 user storage - Added Web UI authentication with SQLite3 user storage
- Added optional HTTPS with auto-generated self-signed certificates - Added optional HTTPS with auto-generated self-signed certificates
@@ -25,7 +31,7 @@ from pathlib import Path
# VERSION # VERSION
# ============================================================================ # ============================================================================
__version__ = "4.1.5" __version__ = "4.2.0"
# ============================================================================ # ============================================================================
# FILE FORMAT # FILE FORMAT
@@ -98,7 +104,7 @@ DEFAULT_PHRASE_WORDS = DEFAULT_PASSPHRASE_WORDS
# RSA configuration # RSA configuration
MIN_RSA_BITS = 2048 MIN_RSA_BITS = 2048
VALID_RSA_SIZES = (2048, 3072, 4096) VALID_RSA_SIZES = (2048, 3072) # 4096 removed - too large for QR codes
DEFAULT_RSA_BITS = 2048 DEFAULT_RSA_BITS = 2048
MIN_KEY_PASSWORD_LENGTH = 8 MIN_KEY_PASSWORD_LENGTH = 8

View File

@@ -82,7 +82,7 @@ def generate_rsa_key(bits: int = DEFAULT_RSA_BITS, password: str | None = None)
Generate an RSA private key in PEM format. Generate an RSA private key in PEM format.
Args: Args:
bits: Key size (2048, 3072, or 4096, default 2048) bits: Key size (2048 or 3072, default 2048)
password: Optional password to encrypt the key password: Optional password to encrypt the key
Returns: Returns:

View File

@@ -136,7 +136,7 @@ def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey:
Generate an RSA private key. Generate an RSA private key.
Args: Args:
bits: Key size (2048, 3072, or 4096) bits: Key size (2048 or 3072)
Returns: Returns:
RSA private key object RSA private key object

View File

@@ -8,6 +8,7 @@ IMPROVEMENTS IN THIS VERSION:
- Much more robust PEM normalization - Much more robust PEM normalization
- Better handling of QR code extraction edge cases - Better handling of QR code extraction edge cases
- Improved error messages - Improved error messages
- v4.2.0: Added zstd compression (better ratio than zlib)
""" """
import base64 import base64
@@ -16,6 +17,14 @@ import zlib
from PIL import Image from PIL import Image
# Optional ZSTD support (better compression ratio)
try:
import zstandard as zstd
HAS_ZSTD = True
except ImportError:
HAS_ZSTD = False
# QR code generation # QR code generation
try: try:
import qrcode import qrcode
@@ -42,30 +51,46 @@ from .constants import (
) )
# Constants # Constants
COMPRESSION_PREFIX = "STEGASOO-Z:" COMPRESSION_PREFIX_ZLIB = "STEGASOO-Z:" # Legacy zlib compression
COMPRESSION_PREFIX_ZSTD = "STEGASOO-ZS:" # v4.2.0: New zstd compression (better ratio)
COMPRESSION_PREFIX = COMPRESSION_PREFIX_ZSTD if HAS_ZSTD else COMPRESSION_PREFIX_ZLIB
def compress_data(data: str) -> str: def compress_data(data: str) -> str:
""" """
Compress string data for QR code storage. Compress string data for QR code storage.
Uses zstd if available (better ratio), falls back to zlib.
Args: Args:
data: String to compress data: String to compress
Returns: Returns:
Compressed string with STEGASOO-Z: prefix Compressed string with STEGASOO-ZS: (zstd) or STEGASOO-Z: (zlib) prefix
""" """
compressed = zlib.compress(data.encode("utf-8"), level=9) data_bytes = data.encode("utf-8")
encoded = base64.b64encode(compressed).decode("ascii")
return COMPRESSION_PREFIX + encoded if HAS_ZSTD:
# Use zstd (better compression ratio)
cctx = zstd.ZstdCompressor(level=19)
compressed = cctx.compress(data_bytes)
encoded = base64.b64encode(compressed).decode("ascii")
return COMPRESSION_PREFIX_ZSTD + encoded
else:
# Fall back to zlib
compressed = zlib.compress(data_bytes, level=9)
encoded = base64.b64encode(compressed).decode("ascii")
return COMPRESSION_PREFIX_ZLIB + encoded
def decompress_data(data: str) -> str: def decompress_data(data: str) -> str:
""" """
Decompress data from QR code. Decompress data from QR code.
Supports both zstd (STEGASOO-ZS:) and zlib (STEGASOO-Z:) formats.
Args: Args:
data: Compressed string with STEGASOO-Z: prefix data: Compressed string with STEGASOO-ZS: or STEGASOO-Z: prefix
Returns: Returns:
Original uncompressed string Original uncompressed string
@@ -73,12 +98,26 @@ def decompress_data(data: str) -> str:
Raises: Raises:
ValueError: If data is not valid compressed format ValueError: If data is not valid compressed format
""" """
if not data.startswith(COMPRESSION_PREFIX): if data.startswith(COMPRESSION_PREFIX_ZSTD):
raise ValueError("Data is not in compressed format") # v4.2.0: ZSTD compression
if not HAS_ZSTD:
raise ValueError(
"Data compressed with zstd but zstandard package not installed. "
"Run: pip install zstandard"
)
encoded = data[len(COMPRESSION_PREFIX_ZSTD):]
compressed = base64.b64decode(encoded)
dctx = zstd.ZstdDecompressor()
return dctx.decompress(compressed).decode("utf-8")
encoded = data[len(COMPRESSION_PREFIX) :] elif data.startswith(COMPRESSION_PREFIX_ZLIB):
compressed = base64.b64decode(encoded) # Legacy zlib compression
return zlib.decompress(compressed).decode("utf-8") encoded = data[len(COMPRESSION_PREFIX_ZLIB):]
compressed = base64.b64decode(encoded)
return zlib.decompress(compressed).decode("utf-8")
else:
raise ValueError("Data is not in compressed format")
def normalize_pem(pem_data: str) -> str: def normalize_pem(pem_data: str) -> str:
@@ -166,8 +205,8 @@ def normalize_pem(pem_data: str) -> str:
def is_compressed(data: str) -> bool: def is_compressed(data: str) -> bool:
"""Check if data has compression prefix.""" """Check if data has compression prefix (zstd or zlib)."""
return data.startswith(COMPRESSION_PREFIX) return data.startswith(COMPRESSION_PREFIX_ZSTD) or data.startswith(COMPRESSION_PREFIX_ZLIB)
def auto_decompress(data: str) -> str: def auto_decompress(data: str) -> str: