3.2.0 Big revamp

This commit is contained in:
Aaron D. Lee
2026-01-01 03:14:35 -05:00
parent 11fc8aab27
commit 657cae0ae6
14 changed files with 2774 additions and 1146 deletions

View File

@@ -0,0 +1,374 @@
# Stegasoo v3.2.0 - Complete Change Summary
## Overview
This update makes two major breaking changes to Stegasoo:
1. **Remove date dependency** - Date no longer used in cryptographic operations
2. **Rename day_phrase → passphrase** - Reflects removal of daily rotation requirement
## Version Information
- **Previous**: v3.1.0 (date-dependent, day_phrase)
- **Current**: v3.2.0 (date-independent, passphrase)
- **Format Version**: 3 → 4 (breaking change)
- **Compatibility**: NOT backward compatible with v3.1.0
## Files Modified
### Core Files (MUST UPDATE)
1. **crypto.py** ✅ Updated
- Removed `date_str` parameter from all functions
- Renamed `day_phrase``passphrase` in all functions
- Removed date from key derivation material
- Simplified header format (no date field)
- Updated error messages
2. **constants.py** ✅ Updated
- Version: `__version__ = "3.2.0"`
- Format: `FORMAT_VERSION = 4`
- Added passphrase constants:
- `MIN_PASSPHRASE_WORDS = 3`
- `MAX_PASSPHRASE_WORDS = 12`
- `DEFAULT_PASSPHRASE_WORDS = 4` (increased from 3)
- `RECOMMENDED_PASSPHRASE_WORDS = 4`
- Kept legacy aliases for transition
3. **models.py** ✅ Updated
- `Credentials`: Changed from `phrases: dict``passphrase: str`
- `EncodeInput`: Renamed `day_phrase``passphrase`, removed `date_str`
- `DecodeInput`: Renamed `day_phrase``passphrase`
- `EncodeResult`: Made `date_used` optional (cosmetic only)
- `DecodeResult`: `date_encoded` always None in v3.2.0
- `ValidationResult`: Added `warning` field
4. **validation.py** ✅ Updated
- Renamed `validate_phrase()``validate_passphrase()`
- Added word count validation with warnings
- Recommends 4+ words for good security
- Updated error messages
### Files Needing Updates
5. **__init__.py** - Public API
- [ ] `encode()`: Remove `date_str`, rename `day_phrase``passphrase`
- [ ] `encode_file()`: Same changes
- [ ] `encode_bytes()`: Same changes
- [ ] `decode()`: Remove `date_str`, rename `day_phrase``passphrase`
- [ ] `decode_text()`: Same changes
- [ ] Update all docstrings
6. **keygen.py** - Key generation
- [ ] `generate_day_phrases()``generate_passphrases()` or keep with new implementation
- [ ] `generate_credentials()`: Update to use single passphrase
- [ ] Update `Credentials` creation
7. **batch.py** - Batch operations
- [ ] `BatchCredentials`: Rename `day_phrase``passphrase`
- [ ] Update all batch functions
8. **cli.py** - Command line
- [ ] `--phrase``--passphrase` (or keep `--phrase` for simplicity)
- [ ] Update help text
- [ ] Update credentials dict creation
9. **steganography.py** - No changes needed
- Uses keys from crypto module, doesn't directly handle phrases/dates
10. **dct_steganography.py** - No changes needed
- Uses keys from crypto module
### Optional/Documentation Files
11. **utils.py** - Keep as-is (organizational functions)
12. **debug.py** - No changes needed
13. **exceptions.py** - No changes needed
14. **compression.py** - No changes needed
15. **qr_utils.py** - No changes needed
## Key Changes Breakdown
### 1. Function Signatures
**Before (v3.1.0):**
```python
def derive_hybrid_key(
photo_data: bytes,
day_phrase: str,
date_str: str,
salt: bytes,
pin: str = "",
rsa_key_data: Optional[bytes] = None
) -> bytes:
```
**After (v3.2.0):**
```python
def derive_hybrid_key(
photo_data: bytes,
passphrase: str,
salt: bytes,
pin: str = "",
rsa_key_data: Optional[bytes] = None
) -> bytes:
```
### 2. Key Derivation Material
**Before:**
```python
key_material = (
photo_hash +
day_phrase.lower().encode() +
pin.encode() +
date_str.encode() + # ← REMOVED
salt
)
```
**After:**
```python
key_material = (
photo_hash +
passphrase.lower().encode() +
pin.encode() +
salt
)
```
### 3. Header Format
**Before (v3.1.0):** 66+ bytes
```
[Magic:4][Version:1][DateLen:1][Date:10][Salt:32][IV:12][Tag:16][Ciphertext]
```
**After (v3.2.0):** 65 bytes
```
[Magic:4][Version:1][Salt:32][IV:12][Tag:16][Ciphertext]
```
### 4. Public API
**Before:**
```python
# Encoding
result = encode(
message="Secret",
reference_photo=photo,
carrier_image=carrier,
day_phrase="apple forest thunder",
pin="123456",
date_str="2025-01-15"
)
# Decoding
decoded = decode(
stego_image=stego,
reference_photo=photo,
day_phrase="apple forest thunder",
pin="123456",
date_str="2025-01-15"
)
```
**After:**
```python
# Encoding
result = encode(
message="Secret",
reference_photo=photo,
carrier_image=carrier,
passphrase="apple forest thunder mountain",
pin="123456"
)
# Decoding
decoded = decode(
stego_image=stego,
reference_photo=photo,
passphrase="apple forest thunder mountain",
pin="123456"
)
```
## Migration Path
### For Users with v3.1.0 Messages
1. **Before upgrading**, decode all messages with v3.1.0:
```bash
# Using v3.1.0
python decode_all.py
```
2. Save the decoded content
3. Upgrade to v3.2.0
4. Re-encode with v3.2.0 if needed
### For Developers
1. Update the 4 core files: crypto.py, constants.py, models.py, validation.py
2. Update remaining files in order:
- `__init__.py` (public API - critical)
- `keygen.py` (credential generation)
- `batch.py` (batch operations)
- `cli.py` (command line)
3. Run tests to verify:
```bash
pytest tests/ -v
```
4. Update documentation and examples
## Benefits
### Simplicity
- ❌ Before: 3 parameters (day_phrase, pin, date)
- ✅ After: 2 parameters (passphrase, pin)
### User Experience
- ❌ Before: "What date did I encode this?" "Which day's phrase?"
- ✅ After: Just use your passphrase
### Asynchronous Ready
- ❌ Before: Must know encoding date
- ✅ After: Decode anytime
### Less Metadata
- ❌ Before: Date stored in header
- ✅ After: No temporal metadata
## Security Considerations
### Entropy Comparison
**v3.1.0:**
- Photo hash: ~128 bits
- Day phrase (3 words): ~33 bits
- PIN (6 digits): ~20 bits
- Date: ~33 bits (10 digits)
- **Total: ~214 bits**
**v3.2.0:**
- Photo hash: ~128 bits
- Passphrase (4 words): ~44 bits
- PIN (6 digits): ~20 bits
- **Total: ~192 bits**
**Mitigation:** Recommend longer passphrases (4-5 words vs 3)
### Best Practices for v3.2.0
1. **Use 4+ word passphrases** (increased from 3)
2. **Keep using PINs** (additional 20 bits)
3. **Protect reference photo** (still critical)
4. **Consider RSA keys** for highest security
## Testing Checklist
- [ ] Unit tests pass
- [ ] Integration tests pass
- [ ] Encode/decode round-trip works
- [ ] File payloads work
- [ ] LSB mode works
- [ ] DCT mode works
- [ ] Batch operations work
- [ ] CLI commands work
- [ ] Error messages are clear
- [ ] Validation works correctly
- [ ] No references to "day_phrase" remain
- [ ] No date parameters remain (except cosmetic)
## Documentation Updates Needed
- [ ] README.md - Update all examples
- [ ] API documentation - Update function signatures
- [ ] Tutorials - Remove date parameters
- [ ] CHANGELOG.md - Add v3.2.0 entry
- [ ] Migration guide - How to upgrade from v3.1.0
- [ ] Examples directory - Update all scripts
## Backward Compatibility Strategy
### Option 1: Clean Break (Recommended)
- No compatibility code
- Clear version separation
- Users must migrate manually
### Option 2: Temporary Wrapper
```python
def encode(
message,
reference_photo,
carrier_image,
passphrase: str = None,
day_phrase: str = None, # Deprecated
date_str: str = None, # Deprecated
pin: str = "",
...
):
if day_phrase and not passphrase:
import warnings
warnings.warn("day_phrase deprecated, use passphrase", DeprecationWarning)
passphrase = day_phrase
if date_str:
warnings.warn("date_str no longer used", DeprecationWarning)
# ... rest of function
```
## Release Checklist
- [ ] All files updated
- [ ] Tests passing
- [ ] Documentation updated
- [ ] Migration guide written
- [ ] CHANGELOG.md updated
- [ ] Version bumped to 3.2.0
- [ ] Git tag created: v3.2.0
- [ ] PyPI package published
- [ ] Release notes published
- [ ] Users notified of breaking changes
## Quick Reference
### Search and Replace Patterns
Safe to replace globally:
- `day_phrase` → `passphrase`
- `day phrase` → `passphrase`
- `Day phrase` → `Passphrase`
- `DEFAULT_PHRASE_WORDS` → `DEFAULT_PASSPHRASE_WORDS`
Do NOT replace:
- `DAY_NAMES` (keep for utilities)
- `get_day_from_date` (keep for utilities)
- `generate_day_phrases` (rename function itself)
### Error Message Updates
- "Day phrase is required" → "Passphrase is required"
- "Check your phrase, PIN" → "Check your passphrase, PIN"
- "the day's phrase" → "the passphrase"
- "today's passphrase" → "passphrase"
## Support
For issues or questions during migration:
1. Check the migration guide
2. Review the comparison document
3. Look at updated examples
4. File an issue on GitHub
---
**Status:**
✅ Core files updated (crypto, constants, models, validation)
⏳ Remaining files need updates (__init__, keygen, batch, cli)
📝 Documentation updates pending

File diff suppressed because it is too large Load Diff

448
src/stegasoo/channel.py Normal file
View File

@@ -0,0 +1,448 @@
"""
Channel Key Management for Stegasoo
A channel key ties encode/decode operations to a specific deployment or group.
Messages encoded with one channel key can only be decoded by systems with the
same channel key configured.
Use cases:
- Organization deployment: IT sets a company-wide channel key
- Friend groups: Share a channel key for private communication
- Air-gapped systems: Generate unique key per installation
- Public instances: No channel key = compatible with any instance without a channel key
Storage priority:
1. Environment variable: STEGASOO_CHANNEL_KEY
2. Config file: ~/.stegasoo/channel.key or ./config/channel.key
3. None (public mode - compatible with any instance without a channel key)
"""
import os
import secrets
import hashlib
import re
from pathlib import Path
from typing import Optional, List
from .debug import debug
# Channel key format: 8 groups of 4 alphanumeric chars (32 chars total)
# Example: ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456
CHANNEL_KEY_PATTERN = re.compile(r'^[A-Z0-9]{4}(-[A-Z0-9]{4}){7}$')
CHANNEL_KEY_LENGTH = 32 # Characters (excluding dashes)
CHANNEL_KEY_FORMATTED_LENGTH = 39 # With dashes
# Environment variable name
CHANNEL_KEY_ENV_VAR = 'STEGASOO_CHANNEL_KEY'
# Config locations (in priority order)
CONFIG_LOCATIONS = [
Path('./config/channel.key'), # Project config
Path.home() / '.stegasoo' / 'channel.key', # User config
]
def generate_channel_key() -> str:
"""
Generate a new random channel key.
Returns:
Formatted channel key (e.g., "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456")
Example:
>>> key = generate_channel_key()
>>> len(key)
39
"""
# Generate 32 random alphanumeric characters
alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789'
raw_key = ''.join(secrets.choice(alphabet) for _ in range(CHANNEL_KEY_LENGTH))
formatted = format_channel_key(raw_key)
debug.print(f"Generated channel key: {get_channel_fingerprint(formatted)}")
return formatted
def format_channel_key(raw_key: str) -> str:
"""
Format a raw key string into the standard format.
Args:
raw_key: Raw key string (with or without dashes)
Returns:
Formatted key with dashes (XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX)
Raises:
ValueError: If key is invalid length or contains invalid characters
Example:
>>> format_channel_key("ABCD1234EFGH5678IJKL9012MNOP3456")
"ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456"
"""
# Remove any existing dashes, spaces, and convert to uppercase
clean = raw_key.replace('-', '').replace(' ', '').upper()
if len(clean) != CHANNEL_KEY_LENGTH:
raise ValueError(
f"Channel key must be {CHANNEL_KEY_LENGTH} characters (got {len(clean)})"
)
# Validate characters
if not all(c in 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' for c in clean):
raise ValueError("Channel key must contain only letters A-Z and digits 0-9")
# Format with dashes every 4 characters
return '-'.join(clean[i:i+4] for i in range(0, CHANNEL_KEY_LENGTH, 4))
def validate_channel_key(key: str) -> bool:
"""
Validate a channel key format.
Args:
key: Channel key to validate
Returns:
True if valid format, False otherwise
Example:
>>> validate_channel_key("ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456")
True
>>> validate_channel_key("invalid")
False
"""
if not key:
return False
try:
formatted = format_channel_key(key)
return bool(CHANNEL_KEY_PATTERN.match(formatted))
except ValueError:
return False
def get_channel_key() -> Optional[str]:
"""
Get the current channel key from environment or config.
Checks in order:
1. STEGASOO_CHANNEL_KEY environment variable
2. ./config/channel.key file
3. ~/.stegasoo/channel.key file
Returns:
Channel key if configured, None if in public mode
Example:
>>> key = get_channel_key()
>>> if key:
... print("Private channel")
... else:
... print("Public mode")
"""
# 1. Check environment variable
env_key = os.environ.get(CHANNEL_KEY_ENV_VAR, '').strip()
if env_key:
if validate_channel_key(env_key):
debug.print(f"Channel key from environment: {get_channel_fingerprint(env_key)}")
return format_channel_key(env_key)
else:
debug.print(f"Warning: Invalid {CHANNEL_KEY_ENV_VAR} format, ignoring")
# 2. Check config files
for config_path in CONFIG_LOCATIONS:
if config_path.exists():
try:
key = config_path.read_text().strip()
if key and validate_channel_key(key):
debug.print(f"Channel key from {config_path}: {get_channel_fingerprint(key)}")
return format_channel_key(key)
except (IOError, PermissionError) as e:
debug.print(f"Could not read {config_path}: {e}")
continue
# 3. No channel key configured (public mode)
debug.print("No channel key configured (public mode)")
return None
def set_channel_key(key: str, location: str = 'project') -> Path:
"""
Save a channel key to config file.
Args:
key: Channel key to save (will be formatted)
location: 'project' for ./config/ or 'user' for ~/.stegasoo/
Returns:
Path where key was saved
Raises:
ValueError: If key format is invalid
Example:
>>> path = set_channel_key("ABCD1234EFGH5678IJKL9012MNOP3456")
>>> print(path)
./config/channel.key
"""
formatted = format_channel_key(key)
if location == 'user':
config_path = Path.home() / '.stegasoo' / 'channel.key'
else:
config_path = Path('./config/channel.key')
# Create directory if needed
config_path.parent.mkdir(parents=True, exist_ok=True)
# Write key with newline
config_path.write_text(formatted + '\n')
# Set restrictive permissions (owner read/write only)
try:
config_path.chmod(0o600)
except (OSError, AttributeError):
pass # Windows doesn't support chmod the same way
debug.print(f"Channel key saved to {config_path}")
return config_path
def clear_channel_key(location: str = 'all') -> List[Path]:
"""
Remove channel key configuration.
Args:
location: 'project', 'user', or 'all'
Returns:
List of paths that were deleted
Example:
>>> deleted = clear_channel_key('all')
>>> print(f"Removed {len(deleted)} files")
"""
deleted = []
paths_to_check = []
if location in ('project', 'all'):
paths_to_check.append(Path('./config/channel.key'))
if location in ('user', 'all'):
paths_to_check.append(Path.home() / '.stegasoo' / 'channel.key')
for path in paths_to_check:
if path.exists():
try:
path.unlink()
deleted.append(path)
debug.print(f"Removed channel key: {path}")
except (IOError, PermissionError) as e:
debug.print(f"Could not remove {path}: {e}")
return deleted
def get_channel_key_hash(key: Optional[str] = None) -> Optional[bytes]:
"""
Get the channel key as a 32-byte hash suitable for key derivation.
This hash is mixed into the Argon2 key derivation to bind
encryption to a specific channel.
Args:
key: Channel key (if None, reads from config)
Returns:
32-byte SHA-256 hash of channel key, or None if no channel key
Example:
>>> hash_bytes = get_channel_key_hash()
>>> if hash_bytes:
... print(f"Hash: {len(hash_bytes)} bytes")
"""
if key is None:
key = get_channel_key()
if not key:
return None
# Hash the formatted key to get consistent 32 bytes
formatted = format_channel_key(key)
return hashlib.sha256(formatted.encode('utf-8')).digest()
def get_channel_fingerprint(key: Optional[str] = None) -> Optional[str]:
"""
Get a short fingerprint for display purposes.
Shows first and last 4 chars with masked middle.
Args:
key: Channel key (if None, reads from config)
Returns:
Fingerprint like "ABCD-••••-••••-••••-••••-••••-••••-3456" or None
Example:
>>> print(get_channel_fingerprint())
ABCD-••••-••••-••••-••••-••••-••••-3456
"""
if key is None:
key = get_channel_key()
if not key:
return None
formatted = format_channel_key(key)
parts = formatted.split('-')
# Show first and last group, mask the rest
masked = [parts[0]] + ['••••'] * 6 + [parts[-1]]
return '-'.join(masked)
def get_channel_status() -> dict:
"""
Get comprehensive channel key status.
Returns:
Dictionary with:
- mode: 'private' or 'public'
- configured: bool
- fingerprint: masked key or None
- source: where key came from or None
- key: full key (for export) or None
Example:
>>> status = get_channel_status()
>>> print(f"Mode: {status['mode']}")
Mode: private
"""
key = get_channel_key()
if key:
# Find which source provided the key
source = 'unknown'
env_key = os.environ.get(CHANNEL_KEY_ENV_VAR, '').strip()
if env_key and validate_channel_key(env_key):
source = 'environment'
else:
for config_path in CONFIG_LOCATIONS:
if config_path.exists():
try:
file_key = config_path.read_text().strip()
if file_key and format_channel_key(file_key) == key:
source = str(config_path)
break
except (IOError, PermissionError):
continue
return {
'mode': 'private',
'configured': True,
'fingerprint': get_channel_fingerprint(key),
'source': source,
'key': key,
}
else:
return {
'mode': 'public',
'configured': False,
'fingerprint': None,
'source': None,
'key': None,
}
def has_channel_key() -> bool:
"""
Quick check if a channel key is configured.
Returns:
True if channel key is set, False for public mode
Example:
>>> if has_channel_key():
... print("Private channel active")
"""
return get_channel_key() is not None
# =============================================================================
# CLI SUPPORT
# =============================================================================
if __name__ == '__main__':
import sys
def print_status():
"""Print current channel status."""
status = get_channel_status()
print(f"Mode: {status['mode'].upper()}")
if status['configured']:
print(f"Fingerprint: {status['fingerprint']}")
print(f"Source: {status['source']}")
else:
print("No channel key configured (public mode)")
if len(sys.argv) < 2:
print("Channel Key Manager")
print("=" * 40)
print_status()
print()
print("Commands:")
print(" python -m stegasoo.channel generate - Generate new key")
print(" python -m stegasoo.channel set <KEY> - Set channel key")
print(" python -m stegasoo.channel show - Show full key")
print(" python -m stegasoo.channel clear - Remove channel key")
print(" python -m stegasoo.channel status - Show status")
sys.exit(0)
cmd = sys.argv[1].lower()
if cmd == 'generate':
key = generate_channel_key()
print(f"Generated channel key:")
print(f" {key}")
print()
save = input("Save to config? [y/N]: ").strip().lower()
if save == 'y':
path = set_channel_key(key)
print(f"Saved to: {path}")
elif cmd == 'set':
if len(sys.argv) < 3:
print("Usage: python -m stegasoo.channel set <KEY>")
sys.exit(1)
try:
key = sys.argv[2]
formatted = format_channel_key(key)
path = set_channel_key(formatted)
print(f"Channel key set: {get_channel_fingerprint(formatted)}")
print(f"Saved to: {path}")
except ValueError as e:
print(f"Error: {e}")
sys.exit(1)
elif cmd == 'show':
status = get_channel_status()
if status['configured']:
print(f"Channel key: {status['key']}")
print(f"Source: {status['source']}")
else:
print("No channel key configured")
elif cmd == 'clear':
deleted = clear_channel_key('all')
if deleted:
print(f"Removed channel key from: {', '.join(str(p) for p in deleted)}")
else:
print("No channel key files found")
elif cmd == 'status':
print_status()
else:
print(f"Unknown command: {cmd}")
sys.exit(1)

View File

@@ -1,8 +1,14 @@
"""
Stegasoo Constants and Configuration
Stegasoo Constants and Configuration (v3.2.0 - Date Independent)
Central location for all magic numbers, limits, and crypto parameters.
All version numbers, limits, and configuration values should be defined here.
BREAKING CHANGES in v3.2.0:
- Removed date dependency from cryptographic operations
- Renamed day_phrase → passphrase throughout codebase
- FORMAT_VERSION bumped to 4 to indicate incompatibility
- Increased default passphrase length to compensate for removed date entropy
"""
import os
@@ -12,14 +18,18 @@ from pathlib import Path
# VERSION
# ============================================================================
__version__ = "3.1.0"
__version__ = "3.2.0"
# ============================================================================
# FILE FORMAT
# ============================================================================
MAGIC_HEADER = b'\x89ST3'
FORMAT_VERSION = 3
# FORMAT VERSION HISTORY:
# Version 1-3: Date-dependent encryption (v3.0.x - v3.1.x)
# Version 4: Date-independent encryption (v3.2.0+) - BREAKING CHANGE
FORMAT_VERSION = 4
# Payload type markers
PAYLOAD_TEXT = 0x01
@@ -46,8 +56,14 @@ PBKDF2_ITERATIONS = 600000
# ============================================================================
MAX_IMAGE_PIXELS = 24_000_000 # ~24 megapixels
MIN_IMAGE_PIXELS = 256 * 256 # Minimum viable image size
MAX_MESSAGE_SIZE = 250_000 # 250 KB (text messages)
MAX_MESSAGE_CHARS = 250_000 # Alias for clarity in templates
MIN_MESSAGE_LENGTH = 1 # Minimum message length
MAX_MESSAGE_LENGTH = MAX_MESSAGE_SIZE # Alias for consistency
MAX_PAYLOAD_SIZE = MAX_MESSAGE_SIZE # Maximum payload size (alias)
MAX_FILENAME_LENGTH = 255 # Max filename length to store
# File size limits
@@ -60,10 +76,17 @@ MIN_PIN_LENGTH = 6
MAX_PIN_LENGTH = 9
DEFAULT_PIN_LENGTH = 6
# Phrase configuration
MIN_PHRASE_WORDS = 3
MAX_PHRASE_WORDS = 12
DEFAULT_PHRASE_WORDS = 3
# Passphrase configuration (v3.2.0: renamed from PHRASE to PASSPHRASE)
# Increased defaults to compensate for removed date entropy (~33 bits)
MIN_PASSPHRASE_WORDS = 3
MAX_PASSPHRASE_WORDS = 12
DEFAULT_PASSPHRASE_WORDS = 4 # Increased from 3 (was DEFAULT_PHRASE_WORDS)
RECOMMENDED_PASSPHRASE_WORDS = 4 # Best practice guideline
# Legacy aliases for backward compatibility during transition
MIN_PHRASE_WORDS = MIN_PASSPHRASE_WORDS
MAX_PHRASE_WORDS = MAX_PASSPHRASE_WORDS
DEFAULT_PHRASE_WORDS = DEFAULT_PASSPHRASE_WORDS
# RSA configuration
MIN_RSA_BITS = 2048
@@ -97,8 +120,11 @@ ALLOWED_KEY_EXTENSIONS = {'pem', 'key'}
# Lossless image formats (safe for steganography)
LOSSLESS_FORMATS = {'PNG', 'BMP', 'TIFF'}
# Supported image formats for steganography
SUPPORTED_IMAGE_FORMATS = LOSSLESS_FORMATS
# ============================================================================
# DAYS
# DAYS (kept for organizational/UI purposes, not crypto)
# ============================================================================
DAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday')
@@ -184,7 +210,7 @@ def get_wordlist() -> list[str]:
# =============================================================================
# DCT STEGANOGRAPHY (v3.0)
# DCT STEGANOGRAPHY (v3.0+)
# =============================================================================
# Embedding modes
@@ -200,6 +226,10 @@ DCT_STEP_SIZE = 8 # QIM quantization step
# Valid embedding modes
VALID_EMBED_MODES = {EMBED_MODE_LSB, EMBED_MODE_DCT}
# Capacity estimation constants
LSB_BYTES_PER_PIXEL = 3 / 8 # 3 bits per pixel (RGB, 1 bit per channel) / 8 bits per byte
DCT_BYTES_PER_PIXEL = 0.125 # Approximate for DCT mode (varies by implementation)
def detect_stego_mode(encrypted_data: bytes) -> str:
"""

View File

@@ -1,8 +1,15 @@
"""
Stegasoo Cryptographic Functions
Stegasoo Cryptographic Functions (v3.2.0 - Date Independent)
Key derivation, encryption, and decryption using AES-256-GCM.
Supports both text messages and binary file payloads.
BREAKING CHANGES in v3.2.0:
- Removed date dependency from key derivation
- Renamed day_phrase → passphrase (no daily rotation needed)
- Messages can now be decoded without knowing encoding date
- Enables true asynchronous communication
- NOT backward compatible with v3.1.0 and earlier
"""
import io
@@ -63,8 +70,7 @@ def hash_photo(image_data: bytes) -> bytes:
def derive_hybrid_key(
photo_data: bytes,
day_phrase: str,
date_str: str,
passphrase: str,
salt: bytes,
pin: str = "",
rsa_key_data: Optional[bytes] = None
@@ -74,18 +80,19 @@ def derive_hybrid_key(
Combines:
- Photo hash (something you have)
- Day phrase (something you know, rotates daily)
- Passphrase (something you know)
- PIN (something you know, static)
- RSA key (something you have)
- Date (automatic rotation)
- Salt (random per message)
Uses Argon2id if available, falls back to PBKDF2.
NOTE: v3.2.0 removed date dependency and daily rotation.
Use a strong static passphrase instead (recommend 4+ words).
Args:
photo_data: Reference photo bytes
day_phrase: The day's phrase
date_str: Date string (YYYY-MM-DD)
passphrase: Shared passphrase (recommend 4+ words)
salt: Random salt for this message
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
@@ -101,9 +108,8 @@ def derive_hybrid_key(
key_material = (
photo_hash +
day_phrase.lower().encode() +
passphrase.lower().encode() +
pin.encode() +
date_str.encode() +
salt
)
@@ -139,8 +145,7 @@ def derive_hybrid_key(
def derive_pixel_key(
photo_data: bytes,
day_phrase: str,
date_str: str,
passphrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None
) -> bytes:
@@ -150,10 +155,11 @@ def derive_pixel_key(
This key determines which pixels are used for embedding,
making the message location unpredictable without the correct inputs.
NOTE: v3.2.0 removed date dependency.
Args:
photo_data: Reference photo bytes
day_phrase: The day's phrase
date_str: Date string (YYYY-MM-DD)
passphrase: Shared passphrase
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
@@ -164,9 +170,8 @@ def derive_pixel_key(
material = (
photo_hash +
day_phrase.lower().encode() +
pin.encode() +
date_str.encode()
passphrase.lower().encode() +
pin.encode()
)
if rsa_key_data:
@@ -282,19 +287,16 @@ def _unpack_payload(data: bytes) -> DecodeResult:
def encrypt_message(
message: Union[str, bytes, FilePayload],
photo_data: bytes,
day_phrase: str,
date_str: str,
passphrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None
) -> bytes:
"""
Encrypt message or file using AES-256-GCM with hybrid key derivation.
Message format:
Message format (v3.2.0 - no date):
- Magic header (4 bytes)
- Version (1 byte)
- Date length (1 byte)
- Date string (variable)
- Version (1 byte) = 4
- Salt (32 bytes)
- IV (12 bytes)
- Auth tag (16 bytes)
@@ -303,8 +305,7 @@ def encrypt_message(
Args:
message: Message string, raw bytes, or FilePayload to encrypt
photo_data: Reference photo bytes
day_phrase: The day's phrase
date_str: Date string (YYYY-MM-DD)
passphrase: Shared passphrase (recommend 4+ words for good entropy)
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
@@ -316,7 +317,7 @@ def encrypt_message(
"""
try:
salt = secrets.token_bytes(SALT_SIZE)
key = derive_hybrid_key(photo_data, day_phrase, date_str, salt, pin, rsa_key_data)
key = derive_hybrid_key(photo_data, passphrase, salt, pin, rsa_key_data)
iv = secrets.token_bytes(IV_SIZE)
# Pack payload with type marker
@@ -335,13 +336,10 @@ def encrypt_message(
encryptor.authenticate_additional_data(MAGIC_HEADER + bytes([FORMAT_VERSION]))
ciphertext = encryptor.update(padded_message) + encryptor.finalize()
date_bytes = date_str.encode()
# v3.2.0: Simplified header without date
return (
MAGIC_HEADER +
bytes([FORMAT_VERSION]) +
bytes([len(date_bytes)]) +
date_bytes +
salt +
iv +
encryptor.tag +
@@ -356,13 +354,16 @@ def parse_header(encrypted_data: bytes) -> Optional[dict]:
"""
Parse the header from encrypted data.
v3.2.0: No date field in header.
Args:
encrypted_data: Raw encrypted bytes
Returns:
Dict with date, salt, iv, tag, ciphertext or None if invalid
Dict with salt, iv, tag, ciphertext or None if invalid
"""
if len(encrypted_data) < 10 or encrypted_data[:4] != MAGIC_HEADER:
# Min size: Magic(4) + Version(1) + Salt(32) + IV(12) + Tag(16) = 65 bytes
if len(encrypted_data) < 65 or encrypted_data[:4] != MAGIC_HEADER:
return None
try:
@@ -370,10 +371,7 @@ def parse_header(encrypted_data: bytes) -> Optional[dict]:
if version != FORMAT_VERSION:
return None
date_len = encrypted_data[5]
date_str = encrypted_data[6:6 + date_len].decode()
offset = 6 + date_len
offset = 5
salt = encrypted_data[offset:offset + SALT_SIZE]
offset += SALT_SIZE
iv = encrypted_data[offset:offset + IV_SIZE]
@@ -383,7 +381,6 @@ def parse_header(encrypted_data: bytes) -> Optional[dict]:
ciphertext = encrypted_data[offset:]
return {
'date': date_str,
'salt': salt,
'iv': iv,
'tag': tag,
@@ -396,17 +393,17 @@ def parse_header(encrypted_data: bytes) -> Optional[dict]:
def decrypt_message(
encrypted_data: bytes,
photo_data: bytes,
day_phrase: str,
passphrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None
) -> DecodeResult:
"""
Decrypt message using the embedded date from the header.
Decrypt message (v3.2.0 - no date needed).
Args:
encrypted_data: Encrypted message bytes
photo_data: Reference photo bytes
day_phrase: The day's phrase (must match encoding day)
passphrase: Shared passphrase
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
@@ -423,7 +420,7 @@ def decrypt_message(
try:
key = derive_hybrid_key(
photo_data, day_phrase, header['date'], header['salt'], pin, rsa_key_data
photo_data, passphrase, header['salt'], pin, rsa_key_data
)
cipher = Cipher(
@@ -439,20 +436,21 @@ def decrypt_message(
payload_data = padded_plaintext[:original_length]
result = _unpack_payload(payload_data)
result.date_encoded = header['date']
# Note: No date_encoded field in v3.2.0
return result
except Exception as e:
raise DecryptionError(
"Decryption failed. Check your phrase, PIN, RSA key, and reference photo."
"Decryption failed. Check your passphrase, PIN, RSA key, and reference photo."
) from e
def decrypt_message_text(
encrypted_data: bytes,
photo_data: bytes,
day_phrase: str,
passphrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None
) -> str:
@@ -464,7 +462,7 @@ def decrypt_message_text(
Args:
encrypted_data: Encrypted message bytes
photo_data: Reference photo bytes
day_phrase: The day's phrase
passphrase: Shared passphrase
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
@@ -474,7 +472,7 @@ def decrypt_message_text(
Raises:
DecryptionError: If decryption fails or content is a file
"""
result = decrypt_message(encrypted_data, photo_data, day_phrase, pin, rsa_key_data)
result = decrypt_message(encrypted_data, photo_data, passphrase, pin, rsa_key_data)
if result.is_file:
if result.file_data:
@@ -490,22 +488,6 @@ def decrypt_message_text(
return result.message or ""
def get_date_from_encrypted(encrypted_data: bytes) -> Optional[str]:
"""
Extract the date string from encrypted data without decrypting.
Useful for determining which day's phrase to use.
Args:
encrypted_data: Encrypted message bytes
Returns:
Date string (YYYY-MM-DD) or None if invalid
"""
header = parse_header(encrypted_data)
return header['date'] if header else None
def has_argon2() -> bool:
"""Check if Argon2 is available."""
return HAS_ARGON2

208
src/stegasoo/decode.py Normal file
View File

@@ -0,0 +1,208 @@
"""
Stegasoo Decode Module (v3.2.0)
High-level decoding functions for extracting messages and files from images.
"""
from typing import Optional
from pathlib import Path
from .models import DecodeInput, DecodeResult
from .crypto import decrypt_message
from .steganography import extract_from_image
from .validation import (
require_valid_image,
require_security_factors,
require_valid_pin,
require_valid_rsa_key,
)
from .constants import EMBED_MODE_AUTO
from .exceptions import ExtractionError, DecryptionError
from .debug import debug
def decode(
stego_image: bytes,
reference_photo: bytes,
passphrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None,
embed_mode: str = EMBED_MODE_AUTO,
) -> DecodeResult:
"""
Decode a message or file from a stego image.
Args:
stego_image: Stego image bytes
reference_photo: Shared reference photo bytes
passphrase: Shared passphrase used during encoding
pin: Optional static PIN (if used during encoding)
rsa_key_data: Optional RSA key bytes (if used during encoding)
rsa_password: Optional RSA key password
embed_mode: 'auto' (default), 'lsb', or 'dct'
Returns:
DecodeResult with message or file data
Example:
>>> result = decode(
... stego_image=stego_bytes,
... reference_photo=ref_bytes,
... passphrase="apple forest thunder mountain",
... pin="123456"
... )
>>> if result.is_text:
... print(result.message)
... else:
... with open(result.filename, 'wb') as f:
... f.write(result.file_data)
"""
debug.print(f"decode: passphrase length={len(passphrase.split())} words, "
f"mode={embed_mode}")
# Validate inputs
require_valid_image(stego_image, "Stego image")
require_valid_image(reference_photo, "Reference photo")
require_security_factors(pin, rsa_key_data)
if pin:
require_valid_pin(pin)
if rsa_key_data:
require_valid_rsa_key(rsa_key_data, rsa_password)
# Derive pixel/coefficient selection key
from .crypto import derive_pixel_key
pixel_key = derive_pixel_key(
reference_photo, passphrase, pin, rsa_key_data
)
# Extract encrypted data
encrypted = extract_from_image(
stego_image,
pixel_key,
embed_mode=embed_mode,
)
if not encrypted:
debug.print("No data extracted from image")
raise ExtractionError("Could not extract data. Check your credentials and image.")
debug.print(f"Extracted {len(encrypted)} bytes from image")
# Decrypt
result = decrypt_message(
encrypted, reference_photo, passphrase, pin, rsa_key_data
)
debug.print(f"Decryption successful: {result.payload_type}")
return result
def decode_file(
stego_image: bytes,
reference_photo: bytes,
passphrase: str,
output_path: Optional[Path] = None,
pin: str = "",
rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None,
embed_mode: str = EMBED_MODE_AUTO,
) -> Path:
"""
Decode a file from a stego image and save it.
Args:
stego_image: Stego image bytes
reference_photo: Shared reference photo bytes
passphrase: Shared passphrase
output_path: Optional output path (defaults to original filename)
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
rsa_password: Optional RSA key password
embed_mode: 'auto', 'lsb', or 'dct'
Returns:
Path where file was saved
Raises:
DecryptionError: If payload is text, not a file
"""
result = decode(
stego_image,
reference_photo,
passphrase,
pin,
rsa_key_data,
rsa_password,
embed_mode,
)
if not result.is_file:
raise DecryptionError("Payload is a text message, not a file")
if output_path is None:
output_path = Path(result.filename or "extracted_file")
else:
output_path = Path(output_path)
if output_path.is_dir():
output_path = output_path / (result.filename or "extracted_file")
# Write file
output_path.write_bytes(result.file_data or b"")
debug.print(f"File saved to: {output_path}")
return output_path
def decode_text(
stego_image: bytes,
reference_photo: bytes,
passphrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None,
embed_mode: str = EMBED_MODE_AUTO,
) -> str:
"""
Decode a text message from a stego image.
Convenience function that returns just the message string.
Args:
stego_image: Stego image bytes
reference_photo: Shared reference photo bytes
passphrase: Shared passphrase
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
rsa_password: Optional RSA key password
embed_mode: 'auto', 'lsb', or 'dct'
Returns:
Decoded message string
Raises:
DecryptionError: If payload is a file, not text
"""
result = decode(
stego_image,
reference_photo,
passphrase,
pin,
rsa_key_data,
rsa_password,
embed_mode,
)
if result.is_file:
# Try to decode as text
if result.file_data:
try:
return result.file_data.decode('utf-8')
except UnicodeDecodeError:
raise DecryptionError(
f"Payload is a binary file ({result.filename or 'unnamed'}), not text"
)
return ""
return result.message or ""

234
src/stegasoo/encode.py Normal file
View File

@@ -0,0 +1,234 @@
"""
Stegasoo Encode Module (v3.2.0)
High-level encoding functions for hiding messages and files in images.
"""
from typing import Optional, Union
from pathlib import Path
from .models import EncodeInput, EncodeResult, FilePayload
from .crypto import encrypt_message, derive_pixel_key
from .steganography import embed_in_image
from .validation import (
require_valid_payload,
require_valid_image,
require_security_factors,
require_valid_pin,
require_valid_rsa_key,
)
from .utils import generate_filename
from .constants import EMBED_MODE_LSB
from .debug import debug
def encode(
message: Union[str, bytes, FilePayload],
reference_photo: bytes,
carrier_image: bytes,
passphrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None,
output_format: Optional[str] = None,
embed_mode: str = EMBED_MODE_LSB,
dct_output_format: str = "png",
dct_color_mode: str = "grayscale",
) -> EncodeResult:
"""
Encode a message or file into an image.
Args:
message: Text message, raw bytes, or FilePayload to hide
reference_photo: Shared reference photo bytes
carrier_image: Carrier image bytes
passphrase: Shared passphrase (recommend 4+ words)
pin: Optional static PIN
rsa_key_data: Optional RSA private key PEM bytes
rsa_password: Optional password for encrypted RSA key
output_format: Force output format ('PNG', 'BMP') - LSB mode only
embed_mode: 'lsb' (default) or 'dct'
dct_output_format: For DCT mode - 'png' or 'jpeg'
dct_color_mode: For DCT mode - 'grayscale' or 'color'
Returns:
EncodeResult with stego image and metadata
Example:
>>> result = encode(
... message="Secret message",
... reference_photo=ref_bytes,
... carrier_image=carrier_bytes,
... passphrase="apple forest thunder mountain",
... pin="123456"
... )
>>> with open('stego.png', 'wb') as f:
... f.write(result.stego_image)
"""
debug.print(f"encode: passphrase length={len(passphrase.split())} words, "
f"pin={'set' if pin else 'none'}, mode={embed_mode}")
# Validate inputs
require_valid_payload(message)
require_valid_image(reference_photo, "Reference photo")
require_valid_image(carrier_image, "Carrier image")
require_security_factors(pin, rsa_key_data)
if pin:
require_valid_pin(pin)
if rsa_key_data:
require_valid_rsa_key(rsa_key_data, rsa_password)
# Encrypt message
encrypted = encrypt_message(
message, reference_photo, passphrase, pin, rsa_key_data
)
debug.print(f"Encrypted payload: {len(encrypted)} bytes")
# Derive pixel/coefficient selection key
pixel_key = derive_pixel_key(
reference_photo, passphrase, pin, rsa_key_data
)
# Embed in image
stego_data, stats, extension = embed_in_image(
encrypted,
carrier_image,
pixel_key,
output_format=output_format,
embed_mode=embed_mode,
dct_output_format=dct_output_format,
dct_color_mode=dct_color_mode,
)
# Generate filename
filename = generate_filename(extension=extension)
# Create result
if hasattr(stats, 'pixels_modified'):
# LSB mode stats
return EncodeResult(
stego_image=stego_data,
filename=filename,
pixels_modified=stats.pixels_modified,
total_pixels=stats.total_pixels,
capacity_used=stats.capacity_used,
date_used=None, # No longer used in v3.2.0
)
else:
# DCT mode stats
return EncodeResult(
stego_image=stego_data,
filename=filename,
pixels_modified=stats.blocks_used * 64,
total_pixels=stats.blocks_available * 64,
capacity_used=stats.usage_percent / 100.0,
date_used=None,
)
def encode_file(
filepath: Union[str, Path],
reference_photo: bytes,
carrier_image: bytes,
passphrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None,
output_format: Optional[str] = None,
filename_override: Optional[str] = None,
embed_mode: str = EMBED_MODE_LSB,
dct_output_format: str = "png",
dct_color_mode: str = "grayscale",
) -> EncodeResult:
"""
Encode a file into an image.
Convenience wrapper that loads a file and encodes it.
Args:
filepath: Path to file to embed
reference_photo: Shared reference photo bytes
carrier_image: Carrier image bytes
passphrase: Shared passphrase
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
rsa_password: Optional RSA key password
output_format: Force output format - LSB only
filename_override: Override stored filename
embed_mode: 'lsb' or 'dct'
dct_output_format: 'png' or 'jpeg'
dct_color_mode: 'grayscale' or 'color'
Returns:
EncodeResult
"""
payload = FilePayload.from_file(str(filepath), filename_override)
return encode(
message=payload,
reference_photo=reference_photo,
carrier_image=carrier_image,
passphrase=passphrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=rsa_password,
output_format=output_format,
embed_mode=embed_mode,
dct_output_format=dct_output_format,
dct_color_mode=dct_color_mode,
)
def encode_bytes(
data: bytes,
filename: str,
reference_photo: bytes,
carrier_image: bytes,
passphrase: str,
pin: str = "",
rsa_key_data: Optional[bytes] = None,
rsa_password: Optional[str] = None,
output_format: Optional[str] = None,
mime_type: Optional[str] = None,
embed_mode: str = EMBED_MODE_LSB,
dct_output_format: str = "png",
dct_color_mode: str = "grayscale",
) -> EncodeResult:
"""
Encode raw bytes with metadata into an image.
Args:
data: Raw bytes to embed
filename: Filename to associate with data
reference_photo: Shared reference photo bytes
carrier_image: Carrier image bytes
passphrase: Shared passphrase
pin: Optional static PIN
rsa_key_data: Optional RSA key bytes
rsa_password: Optional RSA key password
output_format: Force output format - LSB only
mime_type: MIME type of data
embed_mode: 'lsb' or 'dct'
dct_output_format: 'png' or 'jpeg'
dct_color_mode: 'grayscale' or 'color'
Returns:
EncodeResult
"""
payload = FilePayload(data=data, filename=filename, mime_type=mime_type)
return encode(
message=payload,
reference_photo=reference_photo,
carrier_image=carrier_image,
passphrase=passphrase,
pin=pin,
rsa_key_data=rsa_key_data,
rsa_password=rsa_password,
output_format=output_format,
embed_mode=embed_mode,
dct_output_format=dct_output_format,
dct_color_mode=dct_color_mode,
)

167
src/stegasoo/generate.py Normal file
View File

@@ -0,0 +1,167 @@
"""
Stegasoo Generate Module (v3.2.0)
Public API for generating credentials (PINs, passphrases, RSA keys).
"""
from typing import Optional
from .keygen import (
generate_pin as _generate_pin,
generate_phrase,
generate_rsa_key as _generate_rsa_key,
export_rsa_key_pem,
load_rsa_key,
)
from .models import Credentials
from .constants import (
DEFAULT_PIN_LENGTH,
DEFAULT_PASSPHRASE_WORDS,
DEFAULT_RSA_BITS,
)
from .debug import debug
# Re-export from keygen for convenience
__all__ = [
'generate_pin',
'generate_passphrase',
'generate_rsa_key',
'generate_credentials',
'export_rsa_key_pem',
'load_rsa_key',
]
def generate_pin(length: int = DEFAULT_PIN_LENGTH) -> str:
"""
Generate a random PIN.
PINs never start with zero for usability.
Args:
length: PIN length (6-9 digits, default 6)
Returns:
PIN string
Example:
>>> pin = generate_pin()
>>> len(pin)
6
>>> pin[0] != '0'
True
"""
return _generate_pin(length)
def generate_passphrase(words: int = DEFAULT_PASSPHRASE_WORDS) -> str:
"""
Generate a random passphrase from BIP-39 wordlist.
In v3.2.0, this generates a single passphrase (not daily phrases).
Default is 4 words for good security (increased from 3 in v3.1.0).
Args:
words: Number of words (3-12, default 4)
Returns:
Space-separated passphrase
Example:
>>> passphrase = generate_passphrase(4)
>>> len(passphrase.split())
4
"""
return generate_phrase(words)
def generate_rsa_key(
bits: int = DEFAULT_RSA_BITS,
password: Optional[str] = None
) -> str:
"""
Generate an RSA private key in PEM format.
Args:
bits: Key size (2048, 3072, or 4096, default 2048)
password: Optional password to encrypt the key
Returns:
PEM-encoded key string
Example:
>>> key_pem = generate_rsa_key(2048)
>>> '-----BEGIN PRIVATE KEY-----' in key_pem
True
"""
key_obj = _generate_rsa_key(bits)
pem_bytes = export_rsa_key_pem(key_obj, password)
return pem_bytes.decode('utf-8')
def generate_credentials(
use_pin: bool = True,
use_rsa: bool = False,
pin_length: int = DEFAULT_PIN_LENGTH,
rsa_bits: int = DEFAULT_RSA_BITS,
passphrase_words: int = DEFAULT_PASSPHRASE_WORDS,
rsa_password: Optional[str] = None,
) -> Credentials:
"""
Generate a complete set of credentials.
In v3.2.0, this generates a single passphrase (not daily phrases).
At least one of use_pin or use_rsa must be True.
Args:
use_pin: Whether to generate a PIN
use_rsa: Whether to generate an RSA key
pin_length: PIN length (default 6)
rsa_bits: RSA key size (default 2048)
passphrase_words: Number of words in passphrase (default 4)
rsa_password: Optional password for RSA key
Returns:
Credentials object with passphrase, PIN, and/or RSA key
Raises:
ValueError: If neither PIN nor RSA is selected
Example:
>>> creds = generate_credentials(use_pin=True, use_rsa=False)
>>> len(creds.passphrase.split())
4
>>> len(creds.pin)
6
"""
if not use_pin and not use_rsa:
raise ValueError("Must select at least one security factor (PIN or RSA key)")
debug.print(f"Generating credentials: PIN={use_pin}, RSA={use_rsa}, "
f"passphrase_words={passphrase_words}")
# Generate passphrase (single, not daily)
passphrase = generate_phrase(passphrase_words)
# Generate PIN if requested
pin = _generate_pin(pin_length) if use_pin else None
# Generate RSA key if requested
rsa_key_pem = None
if use_rsa:
rsa_key_obj = _generate_rsa_key(rsa_bits)
rsa_key_bytes = export_rsa_key_pem(rsa_key_obj, rsa_password)
rsa_key_pem = rsa_key_bytes.decode('utf-8')
# Create Credentials object (v3.2.0 format)
creds = Credentials(
passphrase=passphrase,
pin=pin,
rsa_key_pem=rsa_key_pem,
rsa_bits=rsa_bits if use_rsa else None,
words_per_passphrase=passphrase_words,
)
debug.print(f"Credentials generated: {creds.total_entropy} bits total entropy")
return creds

166
src/stegasoo/image_utils.py Normal file
View File

@@ -0,0 +1,166 @@
"""
Stegasoo Image Utilities (v3.2.0)
Functions for analyzing images and comparing capacity.
"""
from typing import Optional
import io
from PIL import Image
from .models import ImageInfo, CapacityComparison
from .steganography import calculate_capacity, has_dct_support
from .constants import EMBED_MODE_LSB, EMBED_MODE_DCT
from .debug import debug
def get_image_info(image_data: bytes) -> ImageInfo:
"""
Get detailed information about an image.
Args:
image_data: Image file bytes
Returns:
ImageInfo with dimensions, format, capacity estimates
Example:
>>> info = get_image_info(carrier_bytes)
>>> print(f"{info.width}x{info.height}, {info.lsb_capacity_kb} KB capacity")
"""
img = Image.open(io.BytesIO(image_data))
width, height = img.size
pixels = width * height
format_str = img.format or "Unknown"
mode = img.mode
# Calculate LSB capacity
lsb_capacity = calculate_capacity(image_data, bits_per_channel=1)
# Calculate DCT capacity if available
dct_capacity = None
if has_dct_support():
try:
from .dct_steganography import calculate_dct_capacity
dct_info = calculate_dct_capacity(image_data)
dct_capacity = dct_info.usable_capacity_bytes
except Exception as e:
debug.print(f"Could not calculate DCT capacity: {e}")
info = ImageInfo(
width=width,
height=height,
pixels=pixels,
format=format_str,
mode=mode,
file_size=len(image_data),
lsb_capacity_bytes=lsb_capacity,
lsb_capacity_kb=lsb_capacity / 1024,
dct_capacity_bytes=dct_capacity,
dct_capacity_kb=dct_capacity / 1024 if dct_capacity else None,
)
debug.print(f"Image info: {width}x{height}, LSB={lsb_capacity} bytes, "
f"DCT={dct_capacity or 'N/A'} bytes")
return info
def compare_capacity(
carrier_image: bytes,
reference_photo: Optional[bytes] = None,
) -> CapacityComparison:
"""
Compare embedding capacity between LSB and DCT modes.
Args:
carrier_image: Carrier image bytes
reference_photo: Optional reference photo (not used in v3.2.0, kept for API compatibility)
Returns:
CapacityComparison with capacity info for both modes
Example:
>>> comparison = compare_capacity(carrier_bytes)
>>> print(f"LSB: {comparison.lsb_kb:.1f} KB")
>>> print(f"DCT: {comparison.dct_kb:.1f} KB")
"""
img = Image.open(io.BytesIO(carrier_image))
width, height = img.size
# LSB capacity
lsb_bytes = calculate_capacity(carrier_image, bits_per_channel=1)
lsb_kb = lsb_bytes / 1024
# DCT capacity
dct_available = has_dct_support()
dct_bytes = None
dct_kb = None
if dct_available:
try:
from .dct_steganography import calculate_dct_capacity
dct_info = calculate_dct_capacity(carrier_image)
dct_bytes = dct_info.usable_capacity_bytes
dct_kb = dct_bytes / 1024
except Exception as e:
debug.print(f"DCT capacity calculation failed: {e}")
dct_available = False
comparison = CapacityComparison(
image_width=width,
image_height=height,
lsb_available=True,
lsb_bytes=lsb_bytes,
lsb_kb=lsb_kb,
lsb_output_format="PNG/BMP (color)",
dct_available=dct_available,
dct_bytes=dct_bytes,
dct_kb=dct_kb,
dct_output_formats=["PNG (grayscale)", "JPEG (grayscale)"] if dct_available else None,
dct_ratio_vs_lsb=(dct_bytes / lsb_bytes * 100) if dct_bytes else None,
)
debug.print(f"Capacity comparison: LSB={lsb_kb:.1f}KB, DCT={dct_kb or 'N/A'}KB")
return comparison
def validate_carrier_capacity(
carrier_image: bytes,
payload_size: int,
embed_mode: str = EMBED_MODE_LSB,
) -> dict:
"""
Check if a payload will fit in a carrier image.
Args:
carrier_image: Carrier image bytes
payload_size: Size of payload in bytes
embed_mode: 'lsb' or 'dct'
Returns:
Dict with 'fits', 'capacity', 'usage_percent', 'headroom'
"""
from .steganography import calculate_capacity_by_mode
capacity_info = calculate_capacity_by_mode(carrier_image, embed_mode)
capacity = capacity_info['capacity_bytes']
# Add encryption overhead estimate
estimated_size = payload_size + 200 # Approximate overhead
fits = estimated_size <= capacity
usage_percent = (estimated_size / capacity * 100) if capacity > 0 else 100.0
headroom = capacity - estimated_size
return {
'fits': fits,
'capacity': capacity,
'payload_size': payload_size,
'estimated_size': estimated_size,
'usage_percent': min(usage_percent, 100.0),
'headroom': headroom,
'mode': embed_mode,
}

View File

@@ -1,27 +1,41 @@
"""
Stegasoo Data Models
Stegasoo Data Models (v3.2.0)
Dataclasses for structured data exchange between modules and frontends.
Changes in v3.2.0:
- Renamed day_phrase → passphrase
- Credentials now uses single passphrase instead of day mapping
- Removed date_str from EncodeInput (date no longer used in crypto)
- Made date_used optional in EncodeResult (cosmetic only)
- Added ImageInfo, CapacityComparison, GenerateResult
"""
from dataclasses import dataclass, field
from datetime import date
from typing import Optional, Union
from typing import Optional, Union, List
@dataclass
class Credentials:
"""Generated credentials for encoding/decoding."""
phrases: dict[str, str] # Day -> phrase mapping
"""
Generated credentials for encoding/decoding.
v3.2.0: Simplified to use single passphrase instead of daily rotation.
"""
passphrase: str # Single passphrase (no daily rotation)
pin: Optional[str] = None
rsa_key_pem: Optional[str] = None
rsa_bits: Optional[int] = None
words_per_phrase: int = 3
words_per_passphrase: int = 4 # Increased from 3 in v3.1.0
# Optional: backup passphrases for multi-factor or rotation
backup_passphrases: Optional[list[str]] = None
@property
def phrase_entropy(self) -> int:
"""Entropy in bits from phrases (~11 bits per BIP-39 word)."""
return self.words_per_phrase * 11
def passphrase_entropy(self) -> int:
"""Entropy in bits from passphrase (~11 bits per BIP-39 word)."""
return self.words_per_passphrase * 11
@property
def pin_entropy(self) -> int:
@@ -40,7 +54,13 @@ class Credentials:
@property
def total_entropy(self) -> int:
"""Total entropy in bits (excluding reference photo)."""
return self.phrase_entropy + self.pin_entropy + self.rsa_entropy
return self.passphrase_entropy + self.pin_entropy + self.rsa_entropy
# Legacy property for compatibility
@property
def phrase_entropy(self) -> int:
"""Alias for passphrase_entropy (backward compatibility)."""
return self.passphrase_entropy
@dataclass
@@ -70,30 +90,33 @@ class FilePayload:
@dataclass
class EncodeInput:
"""Input parameters for encoding a message."""
"""
Input parameters for encoding a message.
v3.2.0: Removed date_str (date no longer used in crypto).
"""
message: Union[str, bytes, FilePayload] # Text, raw bytes, or file
reference_photo: bytes
carrier_image: bytes
day_phrase: str
passphrase: str # Renamed from day_phrase
pin: str = ""
rsa_key_data: Optional[bytes] = None
rsa_password: Optional[str] = None
date_str: Optional[str] = None # YYYY-MM-DD, defaults to today
def __post_init__(self):
if self.date_str is None:
self.date_str = date.today().isoformat()
@dataclass
class EncodeResult:
"""Result of encoding operation."""
"""
Result of encoding operation.
v3.2.0: date_used is now optional/cosmetic (not used in crypto).
"""
stego_image: bytes
filename: str
pixels_modified: int
total_pixels: int
capacity_used: float # 0.0 - 1.0
date_used: str
date_used: Optional[str] = None # Cosmetic only (for filename organization)
@property
def capacity_percent(self) -> float:
@@ -103,10 +126,14 @@ class EncodeResult:
@dataclass
class DecodeInput:
"""Input parameters for decoding a message."""
"""
Input parameters for decoding a message.
v3.2.0: Renamed day_phrase → passphrase, no date needed.
"""
stego_image: bytes
reference_photo: bytes
day_phrase: str
passphrase: str # Renamed from day_phrase
pin: str = ""
rsa_key_data: Optional[bytes] = None
rsa_password: Optional[str] = None
@@ -114,13 +141,17 @@ class DecodeInput:
@dataclass
class DecodeResult:
"""Result of decoding operation."""
"""
Result of decoding operation.
v3.2.0: date_encoded is always None (date removed from crypto).
"""
payload_type: str # 'text' or 'file'
message: Optional[str] = None # For text payloads
file_data: Optional[bytes] = None # For file payloads
filename: Optional[str] = None # Original filename for file payloads
mime_type: Optional[str] = None # MIME type hint
date_encoded: Optional[str] = None
date_encoded: Optional[str] = None # Always None in v3.2.0 (kept for compatibility)
@property
def is_file(self) -> bool:
@@ -165,13 +196,77 @@ class ValidationResult:
is_valid: bool
error_message: str = ""
details: dict = field(default_factory=dict)
warning: Optional[str] = None # v3.2.0: Added for passphrase length warnings
@classmethod
def ok(cls, **details) -> 'ValidationResult':
def ok(cls, warning: Optional[str] = None, **details) -> 'ValidationResult':
"""Create a successful validation result."""
return cls(is_valid=True, details=details)
result = cls(is_valid=True, details=details)
if warning:
result.warning = warning
return result
@classmethod
def error(cls, message: str, **details) -> 'ValidationResult':
"""Create a failed validation result."""
return cls(is_valid=False, error_message=message, details=details)
# =============================================================================
# NEW MODELS FOR V3.2.0 PUBLIC API
# =============================================================================
@dataclass
class ImageInfo:
"""Information about an image for steganography."""
width: int
height: int
pixels: int
format: str
mode: str
file_size: int
lsb_capacity_bytes: int
lsb_capacity_kb: float
dct_capacity_bytes: Optional[int] = None
dct_capacity_kb: Optional[float] = None
@dataclass
class CapacityComparison:
"""Comparison of embedding capacity between modes."""
image_width: int
image_height: int
lsb_available: bool
lsb_bytes: int
lsb_kb: float
lsb_output_format: str
dct_available: bool
dct_bytes: Optional[int] = None
dct_kb: Optional[float] = None
dct_output_formats: Optional[List[str]] = None
dct_ratio_vs_lsb: Optional[float] = None
@dataclass
class GenerateResult:
"""Result of credential generation."""
passphrase: str
pin: Optional[str] = None
rsa_key_pem: Optional[str] = None
passphrase_words: int = 4
passphrase_entropy: int = 0
pin_entropy: int = 0
rsa_entropy: int = 0
total_entropy: int = 0
def __str__(self) -> str:
lines = [
"Generated Credentials:",
f" Passphrase: {self.passphrase}",
]
if self.pin:
lines.append(f" PIN: {self.pin}")
if self.rsa_key_pem:
lines.append(f" RSA Key: {len(self.rsa_key_pem)} bytes PEM")
lines.append(f" Total Entropy: {self.total_entropy} bits")
return "\n".join(lines)

View File

@@ -1,7 +1,12 @@
"""
Stegasoo Input Validation
Stegasoo Input Validation (v3.2.0)
Validators for all user inputs with clear error messages.
Changes in v3.2.0:
- Renamed validate_phrase() → validate_passphrase()
- Added word count validation with warnings for passphrases
- Added validators for embed modes and DCT parameters
"""
import io
@@ -14,6 +19,8 @@ from .constants import (
MAX_MESSAGE_SIZE, MAX_FILE_PAYLOAD_SIZE, MAX_IMAGE_PIXELS, MAX_FILE_SIZE,
MIN_RSA_BITS, MIN_KEY_PASSWORD_LENGTH,
ALLOWED_IMAGE_EXTENSIONS, ALLOWED_KEY_EXTENSIONS,
MIN_PASSPHRASE_WORDS, RECOMMENDED_PASSPHRASE_WORDS,
EMBED_MODE_LSB, EMBED_MODE_DCT, EMBED_MODE_AUTO,
)
from .models import ValidationResult, FilePayload
from .exceptions import (
@@ -325,55 +332,110 @@ def validate_key_password(password: str) -> ValidationResult:
return ValidationResult.ok(length=len(password))
def validate_phrase(phrase: str) -> ValidationResult:
def validate_passphrase(passphrase: str) -> ValidationResult:
"""
Validate day phrase.
Validate passphrase.
v3.2.0: Recommend 4+ words for good entropy (since date is no longer used).
Args:
phrase: Phrase string
passphrase: Passphrase string
Returns:
ValidationResult with word_count
ValidationResult with word_count and optional warning
"""
if not phrase or not phrase.strip():
return ValidationResult.error("Day phrase is required")
if not passphrase or not passphrase.strip():
return ValidationResult.error("Passphrase is required")
words = phrase.strip().split()
words = passphrase.strip().split()
if len(words) < MIN_PASSPHRASE_WORDS:
return ValidationResult.error(
f"Passphrase should have at least {MIN_PASSPHRASE_WORDS} words"
)
# Provide warning if below recommended length
if len(words) < RECOMMENDED_PASSPHRASE_WORDS:
return ValidationResult.ok(
word_count=len(words),
warning=f"Recommend {RECOMMENDED_PASSPHRASE_WORDS}+ words for better security"
)
return ValidationResult.ok(word_count=len(words))
def validate_date_string(date_str: str) -> ValidationResult:
# =============================================================================
# NEW VALIDATORS FOR V3.2.0
# =============================================================================
def validate_reference_photo(photo_data: bytes) -> ValidationResult:
"""Validate reference photo. Alias for validate_image."""
return validate_image(photo_data, "Reference photo")
def validate_carrier(carrier_data: bytes) -> ValidationResult:
"""Validate carrier image. Alias for validate_image."""
return validate_image(carrier_data, "Carrier image")
def validate_embed_mode(mode: str) -> ValidationResult:
"""
Validate date string format (YYYY-MM-DD).
Validate embedding mode.
Args:
date_str: Date string
mode: Embedding mode string
Returns:
ValidationResult
"""
if not date_str:
return ValidationResult.error("Date is required")
valid_modes = {EMBED_MODE_LSB, EMBED_MODE_DCT, EMBED_MODE_AUTO}
if len(date_str) != 10:
return ValidationResult.error("Date must be in YYYY-MM-DD format")
if mode not in valid_modes:
return ValidationResult.error(
f"Invalid embed_mode: '{mode}'. Valid options: {', '.join(sorted(valid_modes))}"
)
if date_str[4] != '-' or date_str[7] != '-':
return ValidationResult.error("Date must be in YYYY-MM-DD format")
return ValidationResult.ok(mode=mode)
def validate_dct_output_format(format_str: str) -> ValidationResult:
"""
Validate DCT output format.
try:
year = int(date_str[0:4])
month = int(date_str[5:7])
day = int(date_str[8:10])
Args:
format_str: Output format ('png' or 'jpeg')
if not (1 <= month <= 12 and 1 <= day <= 31 and 2000 <= year <= 2100):
return ValidationResult.error("Invalid date values")
Returns:
ValidationResult
"""
valid_formats = {'png', 'jpeg'}
if format_str.lower() not in valid_formats:
return ValidationResult.error(
f"Invalid DCT output format: '{format_str}'. Valid options: {', '.join(sorted(valid_formats))}"
)
return ValidationResult.ok(format=format_str.lower())
def validate_dct_color_mode(mode: str) -> ValidationResult:
"""
Validate DCT color mode.
Args:
mode: Color mode ('grayscale' or 'color')
return ValidationResult.ok(year=year, month=month, day=day)
except ValueError:
return ValidationResult.error("Date must contain valid numbers")
Returns:
ValidationResult
"""
valid_modes = {'grayscale', 'color'}
if mode.lower() not in valid_modes:
return ValidationResult.error(
f"Invalid DCT color mode: '{mode}'. Valid options: {', '.join(sorted(valid_modes))}"
)
return ValidationResult.ok(mode=mode.lower())
# ============================================================================