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 |
| `--rsa/--no-rsa` | | flag | `--no-rsa` | Generate an RSA key |
| `--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 |
| `--output` | `-o` | path | | Save RSA key to file |
| `--password` | `-p` | string | | Password for RSA key file |
@@ -180,7 +180,7 @@ stegasoo generate
stegasoo generate --words 6
# Generate with RSA key
stegasoo generate --rsa --rsa-bits 4096
stegasoo generate --rsa --rsa-bits 3072
# Save RSA key to encrypted file
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 |
| PIN length | 6-9 | 6 | Digits in the PIN |
| 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

View File

@@ -126,7 +126,7 @@ Quick reference for all Jinja2 templates in `frontends/web/templates/`.
- `use_pin` - checkbox
- `pin_length` - PIN digits (6-9)
- `use_rsa` - checkbox
- `rsa_bits` - key size (2048/3072/4096)
- `rsa_bits` - key size (2048/3072)
**Output panels:**
- 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})",
)
@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(
"--words",
@@ -261,7 +261,7 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
Examples:
stegasoo generate
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 --no-pin --rsa
"""

View File

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

View File

@@ -573,7 +573,7 @@
</tr>
<tr>
<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>
<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">
<option value="2048" selected>2048 bits (~128 bits entropy)</option>
<option value="3072">3072 bits (~128 bits entropy)</option>
<option value="4096">4096 bits (~128 bits entropy)</option>
</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>
@@ -286,12 +282,6 @@
<i class="bi bi-shield-exclamation me-1"></i>
<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.
{% 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>

View File

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

View File

@@ -17,6 +17,14 @@ try:
except ImportError:
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):
"""Supported compression algorithms."""
@@ -24,6 +32,7 @@ class CompressionAlgorithm(IntEnum):
NONE = 0
ZLIB = 1
LZ4 = 2
ZSTD = 3 # v4.2.0: Best ratio, fast compression
# Magic bytes for compressed payloads
@@ -72,6 +81,15 @@ def compress(data: bytes, algorithm: CompressionAlgorithm = CompressionAlgorithm
algorithm = CompressionAlgorithm.ZLIB
else:
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:
raise CompressionError(f"Unknown compression algorithm: {algorithm}")
@@ -123,6 +141,15 @@ def decompress(data: bytes) -> bytes:
result = lz4.frame.decompress(compressed_data)
except Exception as 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:
raise CompressionError(f"Unknown compression algorithm: {algorithm}")
@@ -181,6 +208,9 @@ def estimate_compressed_size(
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
elif algorithm == CompressionAlgorithm.LZ4 and HAS_LZ4:
compressed_sample = lz4.frame.compress(sample)
elif algorithm == CompressionAlgorithm.ZSTD and HAS_ZSTD:
cctx = zstd.ZstdCompressor(level=19)
compressed_sample = cctx.compress(sample)
else:
compressed_sample = zlib.compress(sample, level=ZLIB_LEVEL)
@@ -195,14 +225,24 @@ def get_available_algorithms() -> list[CompressionAlgorithm]:
algorithms = [CompressionAlgorithm.NONE, CompressionAlgorithm.ZLIB]
if HAS_LZ4:
algorithms.append(CompressionAlgorithm.LZ4)
if HAS_ZSTD:
algorithms.append(CompressionAlgorithm.ZSTD)
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:
"""Get human-readable algorithm name."""
names = {
CompressionAlgorithm.NONE: "None",
CompressionAlgorithm.ZLIB: "Zlib (deflate)",
CompressionAlgorithm.LZ4: "LZ4 (fast)",
CompressionAlgorithm.ZSTD: "Zstd (best)",
}
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.
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:
- Added Web UI authentication with SQLite3 user storage
- Added optional HTTPS with auto-generated self-signed certificates
@@ -25,7 +31,7 @@ from pathlib import Path
# VERSION
# ============================================================================
__version__ = "4.1.5"
__version__ = "4.2.0"
# ============================================================================
# FILE FORMAT
@@ -98,7 +104,7 @@ DEFAULT_PHRASE_WORDS = DEFAULT_PASSPHRASE_WORDS
# RSA configuration
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
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.
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
Returns:

View File

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

View File

@@ -8,6 +8,7 @@ IMPROVEMENTS IN THIS VERSION:
- Much more robust PEM normalization
- Better handling of QR code extraction edge cases
- Improved error messages
- v4.2.0: Added zstd compression (better ratio than zlib)
"""
import base64
@@ -16,6 +17,14 @@ import zlib
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
try:
import qrcode
@@ -42,30 +51,46 @@ from .constants import (
)
# 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:
"""
Compress string data for QR code storage.
Uses zstd if available (better ratio), falls back to zlib.
Args:
data: String to compress
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)
encoded = base64.b64encode(compressed).decode("ascii")
return COMPRESSION_PREFIX + encoded
data_bytes = data.encode("utf-8")
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:
"""
Decompress data from QR code.
Supports both zstd (STEGASOO-ZS:) and zlib (STEGASOO-Z:) formats.
Args:
data: Compressed string with STEGASOO-Z: prefix
data: Compressed string with STEGASOO-ZS: or STEGASOO-Z: prefix
Returns:
Original uncompressed string
@@ -73,12 +98,26 @@ def decompress_data(data: str) -> str:
Raises:
ValueError: If data is not valid compressed format
"""
if not data.startswith(COMPRESSION_PREFIX):
raise ValueError("Data is not in compressed format")
if data.startswith(COMPRESSION_PREFIX_ZSTD):
# 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) :]
compressed = base64.b64decode(encoded)
return zlib.decompress(compressed).decode("utf-8")
elif data.startswith(COMPRESSION_PREFIX_ZLIB):
# Legacy zlib compression
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:
@@ -166,8 +205,8 @@ def normalize_pem(pem_data: str) -> str:
def is_compressed(data: str) -> bool:
"""Check if data has compression prefix."""
return data.startswith(COMPRESSION_PREFIX)
"""Check if data has compression prefix (zstd or zlib)."""
return data.startswith(COMPRESSION_PREFIX_ZSTD) or data.startswith(COMPRESSION_PREFIX_ZLIB)
def auto_decompress(data: str) -> str: