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,500 @@
# API Update Summary for v3.2.0
## Overview
The FastAPI REST API has been updated to align with Stegasoo v3.2.0's breaking changes:
1. **Removed date dependency** - No `date_str` field in requests
2. **Renamed day_phrase → passphrase** - Updated all request/response models
3. **Updated generation** - Now generates single passphrase instead of daily phrases
## Breaking Changes
### Request Model Changes
#### 1. EncodeRequest & EncodeFileRequest
**Before (v3.1.0):**
```python
class EncodeRequest(BaseModel):
message: str
reference_photo_base64: str
carrier_image_base64: str
day_phrase: str # ← Changed to passphrase
pin: str = ""
rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None
date_str: Optional[str] = None # ← REMOVED
embed_mode: EmbedModeType = "lsb"
```
**After (v3.2.0):**
```python
class EncodeRequest(BaseModel):
message: str
reference_photo_base64: str
carrier_image_base64: str
passphrase: str = Field(description="Passphrase (v3.2.0: renamed from day_phrase)")
pin: str = ""
rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None
# date_str removed in v3.2.0
embed_mode: EmbedModeType = "lsb"
dct_output_format: DctOutputFormatType = "png"
dct_color_mode: DctColorModeType = "grayscale"
```
#### 2. DecodeRequest
**Before (v3.1.0):**
```python
class DecodeRequest(BaseModel):
stego_image_base64: str
reference_photo_base64: str
day_phrase: str # ← Changed to passphrase
pin: str = ""
rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None
embed_mode: ExtractModeType = "auto"
```
**After (v3.2.0):**
```python
class DecodeRequest(BaseModel):
stego_image_base64: str
reference_photo_base64: str
passphrase: str = Field(description="Passphrase (v3.2.0: renamed from day_phrase)")
pin: str = ""
rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None
embed_mode: ExtractModeType = "auto"
```
#### 3. GenerateRequest
**Before (v3.1.0):**
```python
class GenerateRequest(BaseModel):
use_pin: bool = True
use_rsa: bool = False
pin_length: int = Field(default=6, ge=MIN_PIN_LENGTH, le=MAX_PIN_LENGTH)
rsa_bits: int = Field(default=2048)
words_per_phrase: int = Field(default=3, ge=MIN_PHRASE_WORDS, le=MAX_PHRASE_WORDS)
```
**After (v3.2.0):**
```python
class GenerateRequest(BaseModel):
use_pin: bool = True
use_rsa: bool = False
pin_length: int = Field(default=6, ge=MIN_PIN_LENGTH, le=MAX_PIN_LENGTH)
rsa_bits: int = Field(default=2048)
words_per_passphrase: int = Field(
default=DEFAULT_PASSPHRASE_WORDS, # = 4, was 3
ge=MIN_PASSPHRASE_WORDS,
le=MAX_PASSPHRASE_WORDS,
description="Words per passphrase (v3.2.0: default increased to 4)"
)
```
### Response Model Changes
#### 1. GenerateResponse
**Before (v3.1.0):**
```python
class GenerateResponse(BaseModel):
phrases: dict[str, str] # Monday -> phrase, Tuesday -> phrase, etc.
pin: Optional[str] = None
rsa_key_pem: Optional[str] = None
entropy: dict[str, int]
```
**After (v3.2.0):**
```python
class GenerateResponse(BaseModel):
passphrase: str = Field(description="Single passphrase (v3.2.0: no daily rotation)")
pin: Optional[str] = None
rsa_key_pem: Optional[str] = None
entropy: dict[str, int]
# Legacy field for compatibility
phrases: Optional[dict[str, str]] = Field(
default=None,
description="Deprecated: Use 'passphrase' instead"
)
```
#### 2. EncodeResponse
**Before (v3.1.0):**
```python
class EncodeResponse(BaseModel):
stego_image_base64: str
filename: str
capacity_used_percent: float
date_used: str
day_of_week: str
embed_mode: str
output_format: str = "png"
color_mode: str = "color"
```
**After (v3.2.0):**
```python
class EncodeResponse(BaseModel):
stego_image_base64: str
filename: str
capacity_used_percent: float
embed_mode: str
output_format: str = "png"
color_mode: str = "color"
# Legacy fields (no longer used in crypto)
date_used: Optional[str] = Field(
default=None,
description="Deprecated: Date no longer used in v3.2.0"
)
day_of_week: Optional[str] = Field(
default=None,
description="Deprecated: Date no longer used in v3.2.0"
)
```
### Endpoint Changes
#### 1. POST /encode
**Before (v3.1.0):**
```json
{
"message": "Secret message",
"reference_photo_base64": "...",
"carrier_image_base64": "...",
"day_phrase": "apple forest thunder",
"date_str": "2025-01-15",
"pin": "123456",
"embed_mode": "lsb"
}
```
**After (v3.2.0):**
```json
{
"message": "Secret message",
"reference_photo_base64": "...",
"carrier_image_base64": "...",
"passphrase": "apple forest thunder mountain",
"pin": "123456",
"embed_mode": "lsb"
}
```
#### 2. POST /decode
**Before (v3.1.0):**
```json
{
"stego_image_base64": "...",
"reference_photo_base64": "...",
"day_phrase": "apple forest thunder",
"pin": "123456",
"embed_mode": "auto"
}
```
**After (v3.2.0):**
```json
{
"stego_image_base64": "...",
"reference_photo_base64": "...",
"passphrase": "apple forest thunder mountain",
"pin": "123456",
"embed_mode": "auto"
}
```
#### 3. POST /generate
**Response Before (v3.1.0):**
```json
{
"phrases": {
"Monday": "apple forest thunder",
"Tuesday": "banana river lightning",
...
},
"pin": "123456",
"rsa_key_pem": null,
"entropy": {
"phrase": 33,
"pin": 20,
"rsa": 0,
"total": 53
}
}
```
**Response After (v3.2.0):**
```json
{
"passphrase": "apple forest thunder mountain",
"pin": "123456",
"rsa_key_pem": null,
"entropy": {
"passphrase": 44,
"pin": 20,
"rsa": 0,
"total": 64
},
"phrases": null
}
```
#### 4. POST /encode/multipart
**Form Fields Before (v3.1.0):**
- `day_phrase` (required)
- `date_str` (optional)
- `reference_photo` (file)
- `carrier` (file)
- ...
**Form Fields After (v3.2.0):**
- `passphrase` (required) ← renamed from day_phrase
- `reference_photo` (file)
- `carrier` (file)
- ... (date_str removed)
**Response Headers Before (v3.1.0):**
```
X-Stegasoo-Date: 2025-01-15
X-Stegasoo-Day: Wednesday
X-Stegasoo-Capacity-Percent: 25.5
X-Stegasoo-Embed-Mode: lsb
```
**Response Headers After (v3.2.0):**
```
X-Stegasoo-Capacity-Percent: 25.5
X-Stegasoo-Embed-Mode: lsb
X-Stegasoo-Output-Format: png
X-Stegasoo-Color-Mode: color
X-Stegasoo-Version: 3.2.0
```
### New Status Endpoint Information
#### GET /
**Added to response:**
```json
{
"version": "3.2.0",
...
"breaking_changes": {
"date_removed": "No date_str parameter needed - encode/decode anytime",
"passphrase_renamed": "day_phrase → passphrase (single passphrase, no daily rotation)",
"format_version": 4,
"backward_compatible": false
}
}
```
## Migration Guide for API Clients
### 1. Update Request Bodies
**Find and replace in client code:**
```javascript
// Before
{
day_phrase: "apple forest thunder",
date_str: "2025-01-15"
}
// After
{
passphrase: "apple forest thunder mountain"
}
```
### 2. Update Response Handling
**Before:**
```javascript
const response = await fetch('/encode', {
method: 'POST',
body: JSON.stringify({
message: "secret",
day_phrase: "words",
date_str: "2025-01-15",
...
})
});
const data = await response.json();
console.log(data.date_used); // "2025-01-15"
console.log(data.day_of_week); // "Wednesday"
```
**After:**
```javascript
const response = await fetch('/encode', {
method: 'POST',
body: JSON.stringify({
message: "secret",
passphrase: "longer words here now",
// date_str removed
...
})
});
const data = await response.json();
// date_used and day_of_week are null in v3.2.0
```
### 3. Update Generate Endpoint Usage
**Before:**
```javascript
const creds = await fetch('/generate', {
method: 'POST',
body: JSON.stringify({ use_pin: true })
}).then(r => r.json());
// Use Monday's phrase
const mondayPhrase = creds.phrases['Monday'];
```
**After:**
```javascript
const creds = await fetch('/generate', {
method: 'POST',
body: JSON.stringify({ use_pin: true })
}).then(r => r.json());
// Use single passphrase
const passphrase = creds.passphrase;
```
### 4. Update Multipart Requests
**Before (JavaScript fetch):**
```javascript
const formData = new FormData();
formData.append('day_phrase', 'apple forest thunder');
formData.append('date_str', '2025-01-15');
formData.append('reference_photo', refPhotoFile);
formData.append('carrier', carrierFile);
formData.append('message', 'secret');
formData.append('pin', '123456');
const response = await fetch('/encode/multipart', {
method: 'POST',
body: formData
});
```
**After (JavaScript fetch):**
```javascript
const formData = new FormData();
formData.append('passphrase', 'apple forest thunder mountain');
// date_str removed
formData.append('reference_photo', refPhotoFile);
formData.append('carrier', carrierFile);
formData.append('message', 'secret');
formData.append('pin', '123456');
const response = await fetch('/encode/multipart', {
method: 'POST',
body: formData
});
```
## Testing Checklist
### Endpoints to Test
- [ ] GET / - Returns v3.2.0 with breaking_changes info
- [ ] GET /modes - Returns mode information
- [ ] POST /generate - Returns single passphrase
- [ ] POST /encode - Works without date_str
- [ ] POST /encode/file - Works without date_str
- [ ] POST /decode - Works without date_str
- [ ] POST /encode/multipart - Accepts passphrase instead of day_phrase
- [ ] POST /decode/multipart - Accepts passphrase instead of day_phrase
- [ ] POST /compare - Still works
- [ ] POST /will-fit - Still works
- [ ] POST /image/info - Still works
- [ ] POST /extract-key-from-qr - Still works
### Validation Tests
- [ ] Reject requests with `day_phrase` field (should get validation error)
- [ ] Reject requests with `date_str` field (should be ignored or error)
- [ ] Accept requests with `passphrase` field
- [ ] Generate response includes `passphrase` field
- [ ] Generate response has `phrases` as null
- [ ] Encode response has `date_used` and `day_of_week` as null
- [ ] Multipart encode works with new field names
- [ ] Response headers updated correctly
## OpenAPI/Swagger Documentation
The FastAPI auto-generated documentation (/docs and /redoc) will automatically reflect the changes:
1. **Models updated** - Request/response schemas show new field names
2. **Descriptions updated** - Field descriptions mention v3.2.0 changes
3. **Examples updated** - Interactive API explorer uses new field names
Users can browse to `/docs` to see the updated API specification.
## Backward Compatibility
**Breaking Change:** API v3.2.0 is NOT backward compatible with v3.1.0
Clients using the old API will encounter:
1. **Validation errors** - Missing required `passphrase` field
2. **Unexpected responses** - `phrases` field will be null
3. **Changed behavior** - Date fields no longer populated
### Migration Timeline Recommendation
1. **Deploy v3.2.0 API** to staging
2. **Update client applications** to use new field names
3. **Test thoroughly** with staging API
4. **Deploy v3.2.0 API** to production
5. **Notify users** of breaking changes
Alternatively, run v3.1.0 and v3.2.0 APIs side-by-side on different paths:
- `/api/v3.1/` - Old API
- `/api/v3.2/` - New API
## Constants Updates
Used in validation:
```python
from stegasoo.constants import (
MIN_PASSPHRASE_WORDS, # = 3
MAX_PASSPHRASE_WORDS, # = 12
DEFAULT_PASSPHRASE_WORDS, # = 4 (increased from 3)
)
```
## Error Messages
All error messages updated:
- "day_phrase is required" → "passphrase is required"
- References to "phrase" now mean "passphrase"
## Implementation Status
✅ All request models updated
✅ All response models updated
✅ All endpoints updated
✅ Multipart endpoints updated
✅ Status endpoint shows breaking changes
✅ Constants imported correctly
✅ Error handling updated
✅ No references to day_phrase in user-facing text
✅ No date_str parameters accepted
Ready for deployment!

View File

@@ -1,9 +1,16 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Stegasoo REST API (v3.0.1) Stegasoo REST API (v3.2.0)
FastAPI-based REST API for steganography operations. FastAPI-based REST API for steganography operations.
Supports both text messages and file embedding. Supports both text messages and file embedding.
CHANGES in v3.2.0:
- Removed date dependency from all operations
- Renamed day_phrase → passphrase
- No date_str parameters needed
- Simplified API for asynchronous communications
NEW in v3.0: LSB and DCT embedding modes. NEW in v3.0: LSB and DCT embedding modes.
NEW in v3.0.1: DCT color mode and JPEG output format. NEW in v3.0.1: DCT color mode and JPEG output format.
""" """
@@ -26,13 +33,12 @@ import stegasoo
from stegasoo import ( from stegasoo import (
encode, decode, generate_credentials, encode, decode, generate_credentials,
validate_image, calculate_capacity, validate_image, calculate_capacity,
get_day_from_date, __version__,
DAY_NAMES, __version__,
StegasooError, DecryptionError, CapacityError, StegasooError, DecryptionError, CapacityError,
has_argon2, has_argon2,
FilePayload, FilePayload,
MAX_FILE_PAYLOAD_SIZE, MAX_FILE_PAYLOAD_SIZE,
# NEW in v3.0 - Embedding modes # Embedding modes
EMBED_MODE_LSB, EMBED_MODE_LSB,
EMBED_MODE_DCT, EMBED_MODE_DCT,
EMBED_MODE_AUTO, EMBED_MODE_AUTO,
@@ -43,7 +49,8 @@ from stegasoo import (
) )
from stegasoo.constants import ( from stegasoo.constants import (
MIN_PIN_LENGTH, MAX_PIN_LENGTH, MIN_PIN_LENGTH, MAX_PIN_LENGTH,
MIN_PHRASE_WORDS, MAX_PHRASE_WORDS, MIN_PASSPHRASE_WORDS, MAX_PASSPHRASE_WORDS,
DEFAULT_PASSPHRASE_WORDS,
VALID_RSA_SIZES, VALID_RSA_SIZES,
) )
@@ -68,6 +75,12 @@ app = FastAPI(
description=""" description="""
Secure steganography with hybrid authentication. Supports text messages and file embedding. Secure steganography with hybrid authentication. Supports text messages and file embedding.
## Version 3.2.0 Changes
- **No date parameters needed** - Encode and decode anytime without tracking dates
- **Single passphrase** - No daily rotation, just use your passphrase
- **True asynchronous communications** - Perfect for dead drops and delayed delivery
## Embedding Modes (v3.0) ## Embedding Modes (v3.0)
- **LSB mode** (default): Spatial LSB embedding, full color output, higher capacity - **LSB mode** (default): Spatial LSB embedding, full color output, higher capacity
@@ -105,25 +118,35 @@ class GenerateRequest(BaseModel):
use_rsa: bool = False use_rsa: bool = False
pin_length: int = Field(default=6, ge=MIN_PIN_LENGTH, le=MAX_PIN_LENGTH) pin_length: int = Field(default=6, ge=MIN_PIN_LENGTH, le=MAX_PIN_LENGTH)
rsa_bits: int = Field(default=2048) rsa_bits: int = Field(default=2048)
words_per_phrase: int = Field(default=3, ge=MIN_PHRASE_WORDS, le=MAX_PHRASE_WORDS) words_per_passphrase: int = Field(
default=DEFAULT_PASSPHRASE_WORDS,
ge=MIN_PASSPHRASE_WORDS,
le=MAX_PASSPHRASE_WORDS,
description="Words per passphrase (v3.2.0: default increased to 4)"
)
class GenerateResponse(BaseModel): class GenerateResponse(BaseModel):
phrases: dict[str, str] passphrase: str = Field(description="Single passphrase (v3.2.0: no daily rotation)")
pin: Optional[str] = None pin: Optional[str] = None
rsa_key_pem: Optional[str] = None rsa_key_pem: Optional[str] = None
entropy: dict[str, int] entropy: dict[str, int]
# Legacy field for compatibility
phrases: Optional[dict[str, str]] = Field(
default=None,
description="Deprecated: Use 'passphrase' instead"
)
class EncodeRequest(BaseModel): class EncodeRequest(BaseModel):
message: str message: str
reference_photo_base64: str reference_photo_base64: str
carrier_image_base64: str carrier_image_base64: str
day_phrase: str passphrase: str = Field(description="Passphrase (v3.2.0: renamed from day_phrase)")
pin: str = "" pin: str = ""
rsa_key_base64: Optional[str] = None rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None rsa_password: Optional[str] = None
date_str: Optional[str] = None # date_str removed in v3.2.0
embed_mode: EmbedModeType = Field( embed_mode: EmbedModeType = Field(
default="lsb", default="lsb",
description="Embedding mode: 'lsb' (default, color) or 'dct' (requires scipy)" description="Embedding mode: 'lsb' (default, color) or 'dct' (requires scipy)"
@@ -146,11 +169,11 @@ class EncodeFileRequest(BaseModel):
mime_type: Optional[str] = None mime_type: Optional[str] = None
reference_photo_base64: str reference_photo_base64: str
carrier_image_base64: str carrier_image_base64: str
day_phrase: str passphrase: str = Field(description="Passphrase (v3.2.0: renamed from day_phrase)")
pin: str = "" pin: str = ""
rsa_key_base64: Optional[str] = None rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None rsa_password: Optional[str] = None
date_str: Optional[str] = None # date_str removed in v3.2.0
embed_mode: EmbedModeType = Field( embed_mode: EmbedModeType = Field(
default="lsb", default="lsb",
description="Embedding mode: 'lsb' (default, color) or 'dct' (requires scipy)" description="Embedding mode: 'lsb' (default, color) or 'dct' (requires scipy)"
@@ -170,8 +193,6 @@ class EncodeResponse(BaseModel):
stego_image_base64: str stego_image_base64: str
filename: str filename: str
capacity_used_percent: float capacity_used_percent: float
date_used: str
day_of_week: str
embed_mode: str = Field(description="Embedding mode used: 'lsb' or 'dct'") embed_mode: str = Field(description="Embedding mode used: 'lsb' or 'dct'")
# NEW in v3.0.1 # NEW in v3.0.1
output_format: str = Field( output_format: str = Field(
@@ -182,12 +203,21 @@ class EncodeResponse(BaseModel):
default="color", default="color",
description="Color mode: 'color' (LSB/DCT color) or 'grayscale' (DCT grayscale)" description="Color mode: 'color' (LSB/DCT color) or 'grayscale' (DCT grayscale)"
) )
# Legacy fields (v3.2.0: no longer used in crypto)
date_used: Optional[str] = Field(
default=None,
description="Deprecated: Date no longer used in v3.2.0"
)
day_of_week: Optional[str] = Field(
default=None,
description="Deprecated: Date no longer used in v3.2.0"
)
class DecodeRequest(BaseModel): class DecodeRequest(BaseModel):
stego_image_base64: str stego_image_base64: str
reference_photo_base64: str reference_photo_base64: str
day_phrase: str passphrase: str = Field(description="Passphrase (v3.2.0: renamed from day_phrase)")
pin: str = "" pin: str = ""
rsa_key_base64: Optional[str] = None rsa_key_base64: Optional[str] = None
rsa_password: Optional[str] = None rsa_password: Optional[str] = None
@@ -268,7 +298,6 @@ class StatusResponse(BaseModel):
has_argon2: bool has_argon2: bool
has_qrcode_read: bool has_qrcode_read: bool
has_dct: bool has_dct: bool
day_names: list[str]
max_payload_kb: int max_payload_kb: int
available_modes: list[str] available_modes: list[str]
# NEW in v3.0.1 # NEW in v3.0.1
@@ -276,6 +305,10 @@ class StatusResponse(BaseModel):
default=None, default=None,
description="DCT mode features (v3.0.1+)" description="DCT mode features (v3.0.1+)"
) )
# NEW in v3.2.0
breaking_changes: dict = Field(
description="v3.2.0 breaking changes"
)
class QrExtractResponse(BaseModel): class QrExtractResponse(BaseModel):
@@ -330,10 +363,15 @@ async def root():
has_argon2=has_argon2(), has_argon2=has_argon2(),
has_qrcode_read=HAS_QR_READ, has_qrcode_read=HAS_QR_READ,
has_dct=has_dct_support(), has_dct=has_dct_support(),
day_names=list(DAY_NAMES),
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024, max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
available_modes=available_modes, available_modes=available_modes,
dct_features=dct_features, dct_features=dct_features,
breaking_changes={
"date_removed": "No date_str parameter needed - encode/decode anytime",
"passphrase_renamed": "day_phrase → passphrase (single passphrase, no daily rotation)",
"format_version": 4,
"backward_compatible": False,
}
) )
@@ -496,6 +534,9 @@ async def api_generate(request: GenerateRequest):
Generate credentials for encoding/decoding. Generate credentials for encoding/decoding.
At least one of use_pin or use_rsa must be True. At least one of use_pin or use_rsa must be True.
v3.2.0: Generates single passphrase (no daily rotation).
Default increased to 4 words for better security.
""" """
if not request.use_pin and not request.use_rsa: if not request.use_pin and not request.use_rsa:
raise HTTPException(400, "Must enable at least one of use_pin or use_rsa") raise HTTPException(400, "Must enable at least one of use_pin or use_rsa")
@@ -509,19 +550,20 @@ async def api_generate(request: GenerateRequest):
use_rsa=request.use_rsa, use_rsa=request.use_rsa,
pin_length=request.pin_length, pin_length=request.pin_length,
rsa_bits=request.rsa_bits, rsa_bits=request.rsa_bits,
words_per_phrase=request.words_per_phrase words_per_passphrase=request.words_per_passphrase
) )
return GenerateResponse( return GenerateResponse(
phrases=creds.phrases, passphrase=creds.passphrase, # v3.2.0: Single passphrase
pin=creds.pin, pin=creds.pin,
rsa_key_pem=creds.rsa_key_pem, rsa_key_pem=creds.rsa_key_pem,
entropy={ entropy={
"phrase": creds.phrase_entropy, "passphrase": creds.passphrase_entropy,
"pin": creds.pin_entropy, "pin": creds.pin_entropy,
"rsa": creds.rsa_entropy, "rsa": creds.rsa_entropy,
"total": creds.total_entropy "total": creds.total_entropy
} },
phrases=None # Legacy field removed
) )
except Exception as e: except Exception as e:
raise HTTPException(500, str(e)) raise HTTPException(500, str(e))
@@ -573,6 +615,8 @@ async def api_encode(request: EncodeRequest):
Images must be base64-encoded. Returns base64-encoded stego image. Images must be base64-encoded. Returns base64-encoded stego image.
v3.2.0: No date_str parameter needed - encode anytime!
NEW in v3.0: Supports embed_mode parameter ('lsb' or 'dct'). NEW in v3.0: Supports embed_mode parameter ('lsb' or 'dct').
NEW in v3.0.1: Supports dct_output_format ('png' or 'jpeg') and dct_color_mode ('grayscale' or 'color'). NEW in v3.0.1: Supports dct_output_format ('png' or 'jpeg') and dct_color_mode ('grayscale' or 'color').
""" """
@@ -592,21 +636,21 @@ async def api_encode(request: EncodeRequest):
request.dct_color_mode request.dct_color_mode
) )
# v3.2.0: No date_str parameter
result = encode( result = encode(
message=request.message, message=request.message,
reference_photo=ref_photo, reference_photo=ref_photo,
carrier_image=carrier, carrier_image=carrier,
day_phrase=request.day_phrase, passphrase=request.passphrase, # v3.2.0: Renamed from day_phrase
pin=request.pin, pin=request.pin,
rsa_key_data=rsa_key, rsa_key_data=rsa_key,
rsa_password=request.rsa_password, rsa_password=request.rsa_password,
date_str=request.date_str, # date_str removed in v3.2.0
embed_mode=request.embed_mode, embed_mode=request.embed_mode,
**dct_params, # NEW in v3.0.1 **dct_params,
) )
stego_b64 = base64.b64encode(result.stego_image).decode('utf-8') stego_b64 = base64.b64encode(result.stego_image).decode('utf-8')
day_of_week = get_day_from_date(result.date_used)
output_format, color_mode, _ = _get_output_info( output_format, color_mode, _ = _get_output_info(
request.embed_mode, request.embed_mode,
@@ -618,11 +662,11 @@ async def api_encode(request: EncodeRequest):
stego_image_base64=stego_b64, stego_image_base64=stego_b64,
filename=result.filename, filename=result.filename,
capacity_used_percent=result.capacity_percent, capacity_used_percent=result.capacity_percent,
date_used=result.date_used,
day_of_week=day_of_week,
embed_mode=request.embed_mode, embed_mode=request.embed_mode,
output_format=output_format, output_format=output_format,
color_mode=color_mode, color_mode=color_mode,
date_used=None, # v3.2.0: No longer used
day_of_week=None, # v3.2.0: No longer used
) )
except CapacityError as e: except CapacityError as e:
@@ -640,6 +684,8 @@ async def api_encode_file(request: EncodeFileRequest):
File data must be base64-encoded. File data must be base64-encoded.
v3.2.0: No date_str parameter needed - encode anytime!
NEW in v3.0: Supports embed_mode parameter ('lsb' or 'dct'). NEW in v3.0: Supports embed_mode parameter ('lsb' or 'dct').
NEW in v3.0.1: Supports dct_output_format and dct_color_mode. NEW in v3.0.1: Supports dct_output_format and dct_color_mode.
""" """
@@ -666,21 +712,21 @@ async def api_encode_file(request: EncodeFileRequest):
request.dct_color_mode request.dct_color_mode
) )
# v3.2.0: No date_str parameter
result = encode( result = encode(
message=payload, message=payload,
reference_photo=ref_photo, reference_photo=ref_photo,
carrier_image=carrier, carrier_image=carrier,
day_phrase=request.day_phrase, passphrase=request.passphrase, # v3.2.0: Renamed from day_phrase
pin=request.pin, pin=request.pin,
rsa_key_data=rsa_key, rsa_key_data=rsa_key,
rsa_password=request.rsa_password, rsa_password=request.rsa_password,
date_str=request.date_str, # date_str removed in v3.2.0
embed_mode=request.embed_mode, embed_mode=request.embed_mode,
**dct_params, # NEW in v3.0.1 **dct_params,
) )
stego_b64 = base64.b64encode(result.stego_image).decode('utf-8') stego_b64 = base64.b64encode(result.stego_image).decode('utf-8')
day_of_week = get_day_from_date(result.date_used)
output_format, color_mode, _ = _get_output_info( output_format, color_mode, _ = _get_output_info(
request.embed_mode, request.embed_mode,
@@ -692,11 +738,11 @@ async def api_encode_file(request: EncodeFileRequest):
stego_image_base64=stego_b64, stego_image_base64=stego_b64,
filename=result.filename, filename=result.filename,
capacity_used_percent=result.capacity_percent, capacity_used_percent=result.capacity_percent,
date_used=result.date_used,
day_of_week=day_of_week,
embed_mode=request.embed_mode, embed_mode=request.embed_mode,
output_format=output_format, output_format=output_format,
color_mode=color_mode, color_mode=color_mode,
date_used=None, # v3.2.0: No longer used
day_of_week=None, # v3.2.0: No longer used
) )
except CapacityError as e: except CapacityError as e:
@@ -718,6 +764,8 @@ async def api_decode(request: DecodeRequest):
Returns payload_type to indicate if result is text or file. Returns payload_type to indicate if result is text or file.
v3.2.0: No date_str parameter needed - decode anytime!
NEW in v3.0: Supports embed_mode parameter ('auto', 'lsb', or 'dct'). NEW in v3.0: Supports embed_mode parameter ('auto', 'lsb', or 'dct').
With 'auto' (default), tries LSB first then DCT. With 'auto' (default), tries LSB first then DCT.
@@ -733,10 +781,11 @@ async def api_decode(request: DecodeRequest):
ref_photo = base64.b64decode(request.reference_photo_base64) ref_photo = base64.b64decode(request.reference_photo_base64)
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
# v3.2.0: No date_str parameter
result = decode( result = decode(
stego_image=stego, stego_image=stego,
reference_photo=ref_photo, reference_photo=ref_photo,
day_phrase=request.day_phrase, passphrase=request.passphrase, # v3.2.0: Renamed from day_phrase
pin=request.pin, pin=request.pin,
rsa_key_data=rsa_key, rsa_key_data=rsa_key,
rsa_password=request.rsa_password, rsa_password=request.rsa_password,
@@ -770,7 +819,7 @@ async def api_decode(request: DecodeRequest):
@app.post("/encode/multipart") @app.post("/encode/multipart")
async def api_encode_multipart( async def api_encode_multipart(
day_phrase: str = Form(...), passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"),
reference_photo: UploadFile = File(...), reference_photo: UploadFile = File(...),
carrier: UploadFile = File(...), carrier: UploadFile = File(...),
message: str = Form(""), message: str = Form(""),
@@ -779,7 +828,7 @@ async def api_encode_multipart(
rsa_key: Optional[UploadFile] = File(None), rsa_key: Optional[UploadFile] = File(None),
rsa_key_qr: Optional[UploadFile] = File(None), rsa_key_qr: Optional[UploadFile] = File(None),
rsa_password: str = Form(""), rsa_password: str = Form(""),
date_str: str = Form(""), # date_str removed in v3.2.0
embed_mode: str = Form("lsb"), embed_mode: str = Form("lsb"),
# NEW in v3.0.1 # NEW in v3.0.1
dct_output_format: str = Form("png"), dct_output_format: str = Form("png"),
@@ -792,6 +841,8 @@ async def api_encode_multipart(
RSA key can be provided as 'rsa_key' (.pem file) or 'rsa_key_qr' (QR code image). RSA key can be provided as 'rsa_key' (.pem file) or 'rsa_key_qr' (QR code image).
Returns the stego image directly with metadata headers. Returns the stego image directly with metadata headers.
v3.2.0: No date_str parameter needed - encode anytime!
NEW in v3.0: Supports embed_mode parameter ('lsb' or 'dct'). NEW in v3.0: Supports embed_mode parameter ('lsb' or 'dct').
NEW in v3.0.1: Supports dct_output_format ('png' or 'jpeg') and dct_color_mode ('grayscale' or 'color'). NEW in v3.0.1: Supports dct_output_format ('png' or 'jpeg') and dct_color_mode ('grayscale' or 'color').
""" """
@@ -849,20 +900,20 @@ async def api_encode_multipart(
# Get DCT parameters # Get DCT parameters
dct_params = _get_dct_params(embed_mode, dct_output_format, dct_color_mode) dct_params = _get_dct_params(embed_mode, dct_output_format, dct_color_mode)
# v3.2.0: No date_str parameter
result = encode( result = encode(
message=payload, message=payload,
reference_photo=ref_data, reference_photo=ref_data,
carrier_image=carrier_data, carrier_image=carrier_data,
day_phrase=day_phrase, passphrase=passphrase, # v3.2.0: Renamed from day_phrase
pin=pin, pin=pin,
rsa_key_data=rsa_key_data, rsa_key_data=rsa_key_data,
rsa_password=effective_password, rsa_password=effective_password,
date_str=date_str if date_str else None, # date_str removed in v3.2.0
embed_mode=embed_mode, embed_mode=embed_mode,
**dct_params, # NEW in v3.0.1 **dct_params,
) )
day_of_week = get_day_from_date(result.date_used)
output_format, color_mode, mime_type = _get_output_info( output_format, color_mode, mime_type = _get_output_info(
embed_mode, dct_output_format, dct_color_mode embed_mode, dct_output_format, dct_color_mode
) )
@@ -872,12 +923,11 @@ async def api_encode_multipart(
media_type=mime_type, media_type=mime_type,
headers={ headers={
"Content-Disposition": f"attachment; filename={result.filename}", "Content-Disposition": f"attachment; filename={result.filename}",
"X-Stegasoo-Date": result.date_used,
"X-Stegasoo-Day": day_of_week,
"X-Stegasoo-Capacity-Percent": f"{result.capacity_percent:.1f}", "X-Stegasoo-Capacity-Percent": f"{result.capacity_percent:.1f}",
"X-Stegasoo-Embed-Mode": embed_mode, "X-Stegasoo-Embed-Mode": embed_mode,
"X-Stegasoo-Output-Format": output_format, # NEW in v3.0.1 "X-Stegasoo-Output-Format": output_format,
"X-Stegasoo-Color-Mode": color_mode, # NEW in v3.0.1 "X-Stegasoo-Color-Mode": color_mode,
"X-Stegasoo-Version": __version__,
} }
) )
@@ -893,7 +943,7 @@ async def api_encode_multipart(
@app.post("/decode/multipart", response_model=DecodeResponse) @app.post("/decode/multipart", response_model=DecodeResponse)
async def api_decode_multipart( async def api_decode_multipart(
day_phrase: str = Form(...), passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"),
reference_photo: UploadFile = File(...), reference_photo: UploadFile = File(...),
stego_image: UploadFile = File(...), stego_image: UploadFile = File(...),
pin: str = Form(""), pin: str = Form(""),
@@ -908,6 +958,8 @@ async def api_decode_multipart(
RSA key can be provided as 'rsa_key' (.pem file) or 'rsa_key_qr' (QR code image). RSA key can be provided as 'rsa_key' (.pem file) or 'rsa_key_qr' (QR code image).
Returns JSON with payload_type indicating text or file. Returns JSON with payload_type indicating text or file.
v3.2.0: No date_str parameter needed - decode anytime!
NEW in v3.0: Supports embed_mode parameter ('auto', 'lsb', or 'dct'). NEW in v3.0: Supports embed_mode parameter ('auto', 'lsb', or 'dct').
Note: Extraction works the same regardless of color mode used during encoding. Note: Extraction works the same regardless of color mode used during encoding.
@@ -944,10 +996,11 @@ async def api_decode_multipart(
# QR code keys are never password-protected # QR code keys are never password-protected
effective_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) effective_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None)
# v3.2.0: No date_str parameter
result = decode( result = decode(
stego_image=stego_data, stego_image=stego_data,
reference_photo=ref_data, reference_photo=ref_data,
day_phrase=day_phrase, passphrase=passphrase, # v3.2.0: Renamed from day_phrase
pin=pin, pin=pin,
rsa_key_data=rsa_key_data, rsa_key_data=rsa_key_data,
rsa_password=effective_password, rsa_password=effective_password,
@@ -1022,7 +1075,7 @@ async def api_image_info(
capacity_bytes=comparison['dct']['capacity_bytes'], capacity_bytes=comparison['dct']['capacity_bytes'],
capacity_kb=round(comparison['dct']['capacity_kb'], 1), capacity_kb=round(comparison['dct']['capacity_kb'], 1),
available=comparison['dct']['available'], available=comparison['dct']['available'],
output_format="PNG/JPEG (grayscale or color)", # Updated for v3.0.1 output_format="PNG/JPEG (grayscale or color)",
), ),
} }

View File

@@ -1,6 +1,11 @@
#!/usr/bin/env python3 #!/usr/bin/env python3
""" """
Stegasoo CLI - Command-line interface for steganography operations (v3.0.1). Stegasoo CLI - Command-line interface for steganography operations (v3.2.0).
CHANGES in v3.2.0:
- Removed date dependency from all operations
- Renamed day_phrase → passphrase
- No longer need to specify or remember encoding dates
Usage: Usage:
stegasoo generate [OPTIONS] stegasoo generate [OPTIONS]
@@ -10,10 +15,6 @@ Usage:
stegasoo info [OPTIONS] stegasoo info [OPTIONS]
stegasoo compare [OPTIONS] stegasoo compare [OPTIONS]
stegasoo modes [OPTIONS] stegasoo modes [OPTIONS]
New in v3.0.1:
- DCT color mode: --dct-color (grayscale or color)
- DCT output format: --dct-format (png or jpeg)
""" """
import sys import sys
@@ -31,13 +32,13 @@ from stegasoo import (
generate_credentials, generate_credentials,
export_rsa_key_pem, load_rsa_key, export_rsa_key_pem, load_rsa_key,
validate_image, calculate_capacity, validate_image, calculate_capacity,
get_day_from_date, parse_date_from_filename, parse_date_from_filename, # Keep for filename parsing only
DAY_NAMES, __version__, __version__,
StegasooError, DecryptionError, ExtractionError, StegasooError, DecryptionError, ExtractionError,
FilePayload, FilePayload,
will_fit, will_fit,
strip_image_metadata, strip_image_metadata,
# NEW in v3.0 - Embedding modes # Embedding modes
EMBED_MODE_LSB, EMBED_MODE_LSB,
EMBED_MODE_DCT, EMBED_MODE_DCT,
EMBED_MODE_AUTO, EMBED_MODE_AUTO,
@@ -79,16 +80,22 @@ def cli():
\b \b
- Reference photo (something you have) - Reference photo (something you have)
- Daily passphrase (something you know) - Passphrase (something you know)
- Static PIN or RSA key (additional security) - Static PIN or RSA key (additional security)
\b \b
Embedding Modes (v3.0): Version 3.2.0 Changes:
- No more date parameters - encode/decode anytime!
- Simplified passphrase (no daily rotation)
- True asynchronous communications
\b
Embedding Modes:
- LSB mode (default): Full color output, higher capacity - LSB mode (default): Full color output, higher capacity
- DCT mode: Frequency domain, ~20% capacity, better stealth - DCT mode: Frequency domain, ~20% capacity, better stealth
\b \b
DCT Options (v3.0.1): DCT Options:
- Color mode: grayscale (default) or color (preserves colors) - Color mode: grayscale (default) or color (preserves colors)
- Output format: png (lossless) or jpeg (smaller, natural) - Output format: png (lossless) or jpeg (smaller, natural)
""" """
@@ -104,7 +111,7 @@ def cli():
@click.option('--rsa/--no-rsa', default=False, help='Generate an RSA key') @click.option('--rsa/--no-rsa', default=False, help='Generate an RSA key')
@click.option('--pin-length', type=click.IntRange(6, 9), default=6, help='PIN length (6-9)') @click.option('--pin-length', type=click.IntRange(6, 9), default=6, help='PIN length (6-9)')
@click.option('--rsa-bits', type=click.Choice(['2048', '3072', '4096']), default='2048', help='RSA key size') @click.option('--rsa-bits', type=click.Choice(['2048', '3072', '4096']), default='2048', help='RSA key size')
@click.option('--words', type=click.IntRange(3, 12), default=3, help='Words per phrase (3-12)') @click.option('--words', type=click.IntRange(3, 12), default=4, help='Words per passphrase (default: 4, was 3 in v3.1)')
@click.option('--output', '-o', type=click.Path(), help='Save RSA key to file (requires password)') @click.option('--output', '-o', type=click.Path(), help='Save RSA key to file (requires password)')
@click.option('--password', '-p', help='Password for RSA key file') @click.option('--password', '-p', help='Password for RSA key file')
@click.option('--json', 'as_json', is_flag=True, help='Output as JSON') @click.option('--json', 'as_json', is_flag=True, help='Output as JSON')
@@ -112,12 +119,16 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
""" """
Generate credentials for encoding/decoding. Generate credentials for encoding/decoding.
Creates daily passphrases and optionally a PIN and/or RSA key. Creates a passphrase and optionally a PIN and/or RSA key.
At least one of --pin or --rsa must be enabled. At least one of --pin or --rsa must be enabled.
v3.2.0: No more daily passphrases - use one strong passphrase!
Default increased to 4 words (from 3) for better security.
\b \b
Examples: Examples:
stegasoo generate stegasoo generate
stegasoo generate --words 5
stegasoo generate --rsa --rsa-bits 4096 stegasoo generate --rsa --rsa-bits 4096
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
@@ -137,17 +148,17 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
use_rsa=rsa, use_rsa=rsa,
pin_length=pin_length, pin_length=pin_length,
rsa_bits=int(rsa_bits), rsa_bits=int(rsa_bits),
words_per_phrase=words words_per_passphrase=words
) )
if as_json: if as_json:
import json import json
data = { data = {
'phrases': creds.phrases, 'passphrase': creds.passphrase,
'pin': creds.pin, 'pin': creds.pin,
'rsa_key': creds.rsa_key_pem, 'rsa_key': creds.rsa_key_pem,
'entropy': { 'entropy': {
'phrase': creds.phrase_entropy, 'passphrase': creds.passphrase_entropy,
'pin': creds.pin_entropy, 'pin': creds.pin_entropy,
'rsa': creds.rsa_entropy, 'rsa': creds.rsa_entropy,
'total': creds.total_entropy, 'total': creds.total_entropy,
@@ -159,7 +170,7 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
# Pretty output # Pretty output
click.echo() click.echo()
click.secho("=" * 60, fg='cyan') click.secho("=" * 60, fg='cyan')
click.secho(" STEGASOO CREDENTIALS", fg='cyan', bold=True) click.secho(" STEGASOO CREDENTIALS (v3.2.0)", fg='cyan', bold=True)
click.secho("=" * 60, fg='cyan') click.secho("=" * 60, fg='cyan')
click.echo() click.echo()
@@ -172,11 +183,8 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
click.secho(f" {creds.pin}", fg='bright_yellow', bold=True) click.secho(f" {creds.pin}", fg='bright_yellow', bold=True)
click.echo() click.echo()
click.secho("--- DAILY PHRASES ---", fg='green') click.secho("--- PASSPHRASE ---", fg='green')
for day in DAY_NAMES: click.secho(f" {creds.passphrase}", fg='bright_white', bold=True)
phrase = creds.phrases[day]
click.echo(f" {day:9} | ", nl=False)
click.secho(phrase, fg='bright_white')
click.echo() click.echo()
if creds.rsa_key_pem: if creds.rsa_key_pem:
@@ -193,7 +201,7 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
click.echo() click.echo()
click.secho("--- SECURITY ---", fg='green') click.secho("--- SECURITY ---", fg='green')
click.echo(f" Phrase entropy: {creds.phrase_entropy} bits") click.echo(f" Passphrase entropy: {creds.passphrase_entropy} bits ({words} words)")
if creds.pin: if creds.pin:
click.echo(f" PIN entropy: {creds.pin_entropy} bits") click.echo(f" PIN entropy: {creds.pin_entropy} bits")
if creds.rsa_key_pem: if creds.rsa_key_pem:
@@ -202,6 +210,9 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
click.secho(f" + photo entropy: 80-256 bits", dim=True) click.secho(f" + photo entropy: 80-256 bits", dim=True)
click.echo() click.echo()
click.secho("NOTE: v3.2.0 removed date dependency - use this passphrase anytime!", fg='cyan')
click.echo()
except Exception as e: except Exception as e:
raise click.ClickException(str(e)) raise click.ClickException(str(e))
@@ -216,13 +227,12 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
@click.option('--message', '-m', help='Text message to encode') @click.option('--message', '-m', help='Text message to encode')
@click.option('--message-file', '-f', type=click.Path(exists=True), help='Read text message from file') @click.option('--message-file', '-f', type=click.Path(exists=True), help='Read text message from file')
@click.option('--embed-file', '-e', type=click.Path(exists=True), help='Embed a file (binary)') @click.option('--embed-file', '-e', type=click.Path(exists=True), help='Embed a file (binary)')
@click.option('--phrase', '-p', required=True, help='Day phrase') @click.option('--passphrase', '-p', required=True, help='Passphrase (v3.2.0: no date needed!)')
@click.option('--pin', help='Static PIN') @click.option('--pin', help='Static PIN')
@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file (.pem)') @click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file (.pem)')
@click.option('--key-qr', type=click.Path(exists=True), help='RSA key from QR code image') @click.option('--key-qr', type=click.Path(exists=True), help='RSA key from QR code image')
@click.option('--key-password', help='RSA key password (for encrypted .pem files)') @click.option('--key-password', help='RSA key password (for encrypted .pem files)')
@click.option('--output', '-o', type=click.Path(), help='Output file (default: auto-generated)') @click.option('--output', '-o', type=click.Path(), help='Output file (default: auto-generated)')
@click.option('--date', 'date_str', help='Date override (YYYY-MM-DD)')
@click.option('--mode', 'embed_mode', type=click.Choice(['lsb', 'dct']), default='lsb', @click.option('--mode', 'embed_mode', type=click.Choice(['lsb', 'dct']), default='lsb',
help='Embedding mode: lsb (default, color) or dct (requires scipy)') help='Embedding mode: lsb (default, color) or dct (requires scipy)')
@click.option('--dct-format', 'dct_output_format', type=click.Choice(['png', 'jpeg']), default='png', @click.option('--dct-format', 'dct_output_format', type=click.Choice(['png', 'jpeg']), default='png',
@@ -230,20 +240,22 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
@click.option('--dct-color', 'dct_color_mode', type=click.Choice(['grayscale', 'color']), default='grayscale', @click.option('--dct-color', 'dct_color_mode', type=click.Choice(['grayscale', 'color']), default='grayscale',
help='DCT color mode: grayscale (default) or color (preserves original colors)') help='DCT color mode: grayscale (default) or color (preserves original colors)')
@click.option('--quiet', '-q', is_flag=True, help='Suppress output except errors') @click.option('--quiet', '-q', is_flag=True, help='Suppress output except errors')
def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key, key_qr, def encode_cmd(ref, carrier, message, message_file, embed_file, passphrase, pin, key, key_qr,
key_password, output, date_str, embed_mode, dct_output_format, dct_color_mode, quiet): key_password, output, embed_mode, dct_output_format, dct_color_mode, quiet):
""" """
Encode a secret message or file into an image. Encode a secret message or file into an image.
Requires a reference photo, carrier image, and day phrase. Requires a reference photo, carrier image, and passphrase.
Must provide either --pin or --key/--key-qr (or both). Must provide either --pin or --key/--key-qr (or both).
v3.2.0: No --date parameter needed! Encode and decode anytime.
For text messages, use -m or -f or pipe via stdin. For text messages, use -m or -f or pipe via stdin.
For binary files, use -e/--embed-file. For binary files, use -e/--embed-file.
RSA key can be provided as a .pem file (--key) or QR code image (--key-qr). RSA key can be provided as a .pem file (--key) or QR code image (--key-qr).
\b \b
Embedding Modes (v3.0): Embedding Modes:
--mode lsb Spatial LSB embedding (default) --mode lsb Spatial LSB embedding (default)
- Full color output (PNG/BMP) - Full color output (PNG/BMP)
- Higher capacity (~375 KB/megapixel) - Higher capacity (~375 KB/megapixel)
@@ -254,7 +266,7 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key
- Better resistance to visual analysis - Better resistance to visual analysis
\b \b
DCT Options (v3.0.1): DCT Options:
--dct-format png Lossless output (default) --dct-format png Lossless output (default)
--dct-format jpeg Smaller file, more natural appearance --dct-format jpeg Smaller file, more natural appearance
@@ -264,18 +276,14 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key
\b \b
Examples: Examples:
# Text message with PIN (LSB mode, default) # Text message with PIN (LSB mode, default)
stegasoo encode -r photo.jpg -c meme.png -p "apple forest" --pin 123456 -m "secret" stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder mountain" --pin 123456 -m "secret"
# DCT mode - grayscale PNG (traditional) # DCT mode - grayscale PNG (traditional)
stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -m "secret" --mode dct stegasoo encode -r photo.jpg -c meme.png -p "secure words here now" --pin 123456 -m "secret" --mode dct
# DCT mode - color JPEG (v3.0.1) # DCT mode - color JPEG
stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -m "secret" \ stegasoo encode -r photo.jpg -c meme.png -p "my strong passphrase" --pin 123456 -m "secret" \
--mode dct --dct-color color --dct-format jpeg --mode dct --dct-color color --dct-format jpeg
# DCT mode - color PNG (best quality + color preservation)
stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -m "secret" \
--mode dct --dct-color color --dct-format png
""" """
# Check DCT mode availability # Check DCT mode availability
if embed_mode == 'dct' and not has_dct_support(): if embed_mode == 'dct' and not has_dct_support():
@@ -365,15 +373,15 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key
mode_desc += f" ({dct_color_mode}, {dct_output_format.upper()})" mode_desc += f" ({dct_color_mode}, {dct_output_format.upper()})"
click.echo(f"Mode: {mode_desc} ({fit_check['usage_percent']:.1f}% capacity)") click.echo(f"Mode: {mode_desc} ({fit_check['usage_percent']:.1f}% capacity)")
# v3.2.0: No date_str parameter
result = encode( result = encode(
message=payload, message=payload,
reference_photo=ref_photo, reference_photo=ref_photo,
carrier_image=carrier_image, carrier_image=carrier_image,
day_phrase=phrase, passphrase=passphrase, # Renamed from day_phrase
pin=pin or "", pin=pin or "",
rsa_key_data=rsa_key_data, rsa_key_data=rsa_key_data,
rsa_password=effective_key_password, rsa_password=effective_key_password,
date_str=date_str,
embed_mode=embed_mode, embed_mode=embed_mode,
dct_output_format=dct_output_format, dct_output_format=dct_output_format,
dct_color_mode=dct_color_mode, dct_color_mode=dct_color_mode,
@@ -389,15 +397,15 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key
out_path.write_bytes(result.stego_image) out_path.write_bytes(result.stego_image)
if not quiet: if not quiet:
click.secho(f"[OK] Encoded successfully!", fg='green') click.secho(f" Encoded successfully!", fg='green')
click.echo(f" Output: {out_path}") click.echo(f" Output: {out_path}")
click.echo(f" Size: {len(result.stego_image):,} bytes") click.echo(f" Size: {len(result.stego_image):,} bytes")
click.echo(f" Capacity used: {result.capacity_percent:.1f}%") click.echo(f" Capacity used: {result.capacity_percent:.1f}%")
click.echo(f" Date: {result.date_used}")
if embed_mode == 'dct': if embed_mode == 'dct':
color_note = "color preserved" if dct_color_mode == 'color' else "grayscale" color_note = "color preserved" if dct_color_mode == 'color' else "grayscale"
format_note = dct_output_format.upper() format_note = dct_output_format.upper()
click.secho(f" DCT output: {format_note} ({color_note})", dim=True) click.secho(f" DCT output: {format_note} ({color_note})", dim=True)
click.secho(" (v3.2.0: No date needed to decode!)", fg='cyan', dim=True)
except StegasooError as e: except StegasooError as e:
raise click.ClickException(str(e)) raise click.ClickException(str(e))
@@ -414,7 +422,7 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key
@cli.command() @cli.command()
@click.option('--ref', '-r', required=True, type=click.Path(exists=True), help='Reference photo') @click.option('--ref', '-r', required=True, type=click.Path(exists=True), help='Reference photo')
@click.option('--stego', '-s', required=True, type=click.Path(exists=True), help='Stego image') @click.option('--stego', '-s', required=True, type=click.Path(exists=True), help='Stego image')
@click.option('--phrase', '-p', required=True, help='Day phrase') @click.option('--passphrase', '-p', required=True, help='Passphrase')
@click.option('--pin', help='Static PIN') @click.option('--pin', help='Static PIN')
@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file (.pem)') @click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file (.pem)')
@click.option('--key-qr', type=click.Path(exists=True), help='RSA key from QR code image') @click.option('--key-qr', type=click.Path(exists=True), help='RSA key from QR code image')
@@ -424,7 +432,7 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key
help='Extraction mode: auto (default), lsb, or dct') help='Extraction mode: auto (default), lsb, or dct')
@click.option('--quiet', '-q', is_flag=True, help='Output only the content (for text) or suppress messages (for files)') @click.option('--quiet', '-q', is_flag=True, help='Output only the content (for text) or suppress messages (for files)')
@click.option('--force', is_flag=True, help='Overwrite existing output file') @click.option('--force', is_flag=True, help='Overwrite existing output file')
def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, embed_mode, quiet, force): def decode_cmd(ref, stego, passphrase, pin, key, key_qr, key_password, output, embed_mode, quiet, force):
""" """
Decode a secret message or file from a stego image. Decode a secret message or file from a stego image.
@@ -432,11 +440,13 @@ def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, embed
Automatically detects whether content is text or a file. Automatically detects whether content is text or a file.
RSA key can be provided as a .pem file (--key) or QR code image (--key-qr). RSA key can be provided as a .pem file (--key) or QR code image (--key-qr).
v3.2.0: No --date parameter needed! Just use your passphrase.
Note: Extraction works the same regardless of whether the image was Note: Extraction works the same regardless of whether the image was
created with color mode or grayscale mode - both use the same Y channel. created with color mode or grayscale mode - both use the same Y channel.
\b \b
Extraction Modes (v3.0): Extraction Modes:
--mode auto Auto-detect (default) - tries LSB first, then DCT --mode auto Auto-detect (default) - tries LSB first, then DCT
--mode lsb Only try LSB extraction --mode lsb Only try LSB extraction
--mode dct Only try DCT extraction (requires scipy) --mode dct Only try DCT extraction (requires scipy)
@@ -444,16 +454,16 @@ def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, embed
\b \b
Examples: Examples:
# Decode with PIN (auto-detect mode) # Decode with PIN (auto-detect mode)
stegasoo decode -r photo.jpg -s stego.png -p "apple forest thunder" --pin 123456 stegasoo decode -r photo.jpg -s stego.png -p "apple forest thunder mountain" --pin 123456
# Explicitly specify DCT mode # Explicitly specify DCT mode
stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 --mode dct stegasoo decode -r photo.jpg -s stego.png -p "my passphrase here" --pin 123456 --mode dct
# Decode with RSA key file # Decode with RSA key file
stegasoo decode -r photo.jpg -s stego.png -p "words" -k mykey.pem stegasoo decode -r photo.jpg -s stego.png -p "strong words" -k mykey.pem
# Save output to file # Save output to file
stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 -o output.txt stegasoo decode -r photo.jpg -s stego.png -p "passphrase" --pin 123456 -o output.txt
""" """
# Check DCT mode availability # Check DCT mode availability
if embed_mode == 'dct' and not has_dct_support(): if embed_mode == 'dct' and not has_dct_support():
@@ -495,10 +505,11 @@ def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, embed
ref_photo = Path(ref).read_bytes() ref_photo = Path(ref).read_bytes()
stego_image = Path(stego).read_bytes() stego_image = Path(stego).read_bytes()
# v3.2.0: No date_str parameter
result = decode( result = decode(
stego_image=stego_image, stego_image=stego_image,
reference_photo=ref_photo, reference_photo=ref_photo,
day_phrase=phrase, passphrase=passphrase, # Renamed from day_phrase
pin=pin or "", pin=pin or "",
rsa_key_data=rsa_key_data, rsa_key_data=rsa_key_data,
rsa_password=effective_key_password, rsa_password=effective_key_password,
@@ -522,7 +533,7 @@ def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, embed
out_path.write_bytes(result.file_data) out_path.write_bytes(result.file_data)
if not quiet: if not quiet:
click.secho("[OK] Decoded file successfully!", fg='green') click.secho(" Decoded file successfully!", fg='green')
click.echo(f" Saved to: {out_path}") click.echo(f" Saved to: {out_path}")
click.echo(f" Size: {len(result.file_data):,} bytes") click.echo(f" Size: {len(result.file_data):,} bytes")
if result.mime_type: if result.mime_type:
@@ -532,13 +543,13 @@ def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, embed
if output: if output:
Path(output).write_text(result.message) Path(output).write_text(result.message)
if not quiet: if not quiet:
click.secho("[OK] Decoded successfully!", fg='green') click.secho(" Decoded successfully!", fg='green')
click.echo(f" Saved to: {output}") click.echo(f" Saved to: {output}")
else: else:
if quiet: if quiet:
click.echo(result.message) click.echo(result.message)
else: else:
click.secho("[OK] Decoded successfully!", fg='green') click.secho(" Decoded successfully!", fg='green')
click.echo() click.echo()
click.echo(result.message) click.echo(result.message)
@@ -557,7 +568,7 @@ def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, embed
@cli.command() @cli.command()
@click.option('--ref', '-r', required=True, type=click.Path(exists=True), help='Reference photo') @click.option('--ref', '-r', required=True, type=click.Path(exists=True), help='Reference photo')
@click.option('--stego', '-s', required=True, type=click.Path(exists=True), help='Stego image') @click.option('--stego', '-s', required=True, type=click.Path(exists=True), help='Stego image')
@click.option('--phrase', '-p', required=True, help='Day phrase') @click.option('--passphrase', '-p', required=True, help='Passphrase')
@click.option('--pin', help='Static PIN') @click.option('--pin', help='Static PIN')
@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file (.pem)') @click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file (.pem)')
@click.option('--key-qr', type=click.Path(exists=True), help='RSA key from QR code image') @click.option('--key-qr', type=click.Path(exists=True), help='RSA key from QR code image')
@@ -565,7 +576,7 @@ def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, embed
@click.option('--mode', 'embed_mode', type=click.Choice(['auto', 'lsb', 'dct']), default='auto', @click.option('--mode', 'embed_mode', type=click.Choice(['auto', 'lsb', 'dct']), default='auto',
help='Extraction mode: auto (default), lsb, or dct') help='Extraction mode: auto (default), lsb, or dct')
@click.option('--json', 'as_json', is_flag=True, help='Output as JSON') @click.option('--json', 'as_json', is_flag=True, help='Output as JSON')
def verify(ref, stego, phrase, pin, key, key_qr, key_password, embed_mode, as_json): def verify(ref, stego, passphrase, pin, key, key_qr, key_password, embed_mode, as_json):
""" """
Verify that a stego image can be decoded without extracting the message. Verify that a stego image can be decoded without extracting the message.
@@ -574,11 +585,11 @@ def verify(ref, stego, phrase, pin, key, key_qr, key_password, embed_mode, as_js
\b \b
Examples: Examples:
stegasoo verify -r photo.jpg -s stego.png -p "apple forest thunder" --pin 123456 stegasoo verify -r photo.jpg -s stego.png -p "my passphrase" --pin 123456
stegasoo verify -r photo.jpg -s stego.png -p "words" -k mykey.pem --json stegasoo verify -r photo.jpg -s stego.png -p "words here" -k mykey.pem --json
stegasoo verify -r photo.jpg -s stego.png -p "words" --pin 123456 --mode dct stegasoo verify -r photo.jpg -s stego.png -p "test phrase" --pin 123456 --mode dct
""" """
# Check DCT mode availability # Check DCT mode availability
if embed_mode == 'dct' and not has_dct_support(): if embed_mode == 'dct' and not has_dct_support():
@@ -620,7 +631,7 @@ def verify(ref, stego, phrase, pin, key, key_qr, key_password, embed_mode, as_js
result = decode( result = decode(
stego_image=stego_image, stego_image=stego_image,
reference_photo=ref_photo, reference_photo=ref_photo,
day_phrase=phrase, passphrase=passphrase, # v3.2.0: Renamed from day_phrase
pin=pin or "", pin=pin or "",
rsa_key_data=rsa_key_data, rsa_key_data=rsa_key_data,
rsa_password=effective_key_password, rsa_password=effective_key_password,
@@ -639,10 +650,6 @@ def verify(ref, stego, phrase, pin, key, key_qr, key_password, embed_mode, as_js
payload_type = "text" payload_type = "text"
payload_desc = f"{payload_size} bytes" payload_desc = f"{payload_size} bytes"
# Get date info
date_encoded = result.date_encoded
day_name = get_day_from_date(date_encoded) if date_encoded else None
if as_json: if as_json:
import json import json
output = { output = {
@@ -650,19 +657,15 @@ def verify(ref, stego, phrase, pin, key, key_qr, key_password, embed_mode, as_js
"stego_file": stego, "stego_file": stego,
"payload_type": payload_type, "payload_type": payload_type,
"payload_size": payload_size, "payload_size": payload_size,
"date_encoded": date_encoded,
"day_encoded": day_name,
} }
if result.is_file: if result.is_file:
output["filename"] = result.filename output["filename"] = result.filename
output["mime_type"] = result.mime_type output["mime_type"] = result.mime_type
click.echo(json.dumps(output, indent=2)) click.echo(json.dumps(output, indent=2))
else: else:
click.secho("[OK] Valid stego image", fg='green', bold=True) click.secho(" Valid stego image", fg='green', bold=True)
click.echo(f" Payload: {payload_type} ({payload_desc})") click.echo(f" Payload: {payload_type} ({payload_desc})")
click.echo(f" Size: {payload_size:,} bytes") click.echo(f" Size: {payload_size:,} bytes")
if date_encoded:
click.echo(f" Encoded: {date_encoded} ({day_name})")
except (DecryptionError, ExtractionError) as e: except (DecryptionError, ExtractionError) as e:
if as_json: if as_json:
@@ -675,7 +678,7 @@ def verify(ref, stego, phrase, pin, key, key_qr, key_password, embed_mode, as_js
click.echo(json.dumps(output, indent=2)) click.echo(json.dumps(output, indent=2))
sys.exit(1) sys.exit(1)
else: else:
click.secho("[FAIL] Verification failed", fg='red', bold=True) click.secho(" Verification failed", fg='red', bold=True)
click.echo(f" Error: {e}") click.echo(f" Error: {e}")
sys.exit(1) sys.exit(1)
except StegasooError as e: except StegasooError as e:
@@ -695,8 +698,7 @@ def info(image, as_json):
""" """
Show information about an image. Show information about an image.
Displays dimensions, capacity for both LSB and DCT modes, Displays dimensions, capacity for both LSB and DCT modes.
and attempts to detect date from filename.
""" """
try: try:
image_data = Path(image).read_bytes() image_data = Path(image).read_bytes()
@@ -708,10 +710,6 @@ def info(image, as_json):
# Get capacity comparison # Get capacity comparison
comparison = compare_modes(image_data) comparison = compare_modes(image_data)
# Try to get date from filename
date_str = parse_date_from_filename(image)
day_name = get_day_from_date(date_str) if date_str else None
if as_json: if as_json:
import json import json
output = { output = {
@@ -736,9 +734,6 @@ def info(image, as_json):
}, },
}, },
} }
if date_str:
output["embed_date"] = date_str
output["embed_day"] = day_name
click.echo(json.dumps(output, indent=2)) click.echo(json.dumps(output, indent=2))
return return
@@ -753,17 +748,13 @@ def info(image, as_json):
click.secho(" Capacity:", bold=True) click.secho(" Capacity:", bold=True)
click.echo(f" LSB mode: ~{comparison['lsb']['capacity_bytes']:,} bytes ({comparison['lsb']['capacity_kb']:.1f} KB)") click.echo(f" LSB mode: ~{comparison['lsb']['capacity_bytes']:,} bytes ({comparison['lsb']['capacity_kb']:.1f} KB)")
dct_status = "[OK]" if comparison['dct']['available'] else "[X] (scipy not installed)" dct_status = "" if comparison['dct']['available'] else " (scipy not installed)"
click.echo(f" DCT mode: ~{comparison['dct']['capacity_bytes']:,} bytes ({comparison['dct']['capacity_kb']:.1f} KB) {dct_status}") click.echo(f" DCT mode: ~{comparison['dct']['capacity_bytes']:,} bytes ({comparison['dct']['capacity_kb']:.1f} KB) {dct_status}")
click.echo(f" DCT ratio: {comparison['dct']['ratio_vs_lsb']:.1f}% of LSB") click.echo(f" DCT ratio: {comparison['dct']['ratio_vs_lsb']:.1f}% of LSB")
if comparison['dct']['available']: if comparison['dct']['available']:
click.secho(" DCT options: grayscale/color, png/jpeg", dim=True) click.secho(" DCT options: grayscale/color, png/jpeg", dim=True)
if date_str:
click.echo()
click.echo(f" Embed date: {date_str} ({day_name})")
click.echo() click.echo()
except Exception as e: except Exception as e:
@@ -839,7 +830,7 @@ def compare(image, payload_size, as_json):
click.secho(" +--- LSB Mode ---", fg='green') click.secho(" +--- LSB Mode ---", fg='green')
click.echo(f" | Capacity: {comparison['lsb']['capacity_bytes']:,} bytes ({comparison['lsb']['capacity_kb']:.1f} KB)") click.echo(f" | Capacity: {comparison['lsb']['capacity_bytes']:,} bytes ({comparison['lsb']['capacity_kb']:.1f} KB)")
click.echo(f" | Output: {comparison['lsb']['output']}") click.echo(f" | Output: {comparison['lsb']['output']}")
click.echo(f" | Status: [OK] Available") click.echo(f" | Status: Available")
click.echo(" |") click.echo(" |")
# DCT mode # DCT mode
@@ -847,11 +838,11 @@ def compare(image, payload_size, as_json):
click.echo(f" | Capacity: {comparison['dct']['capacity_bytes']:,} bytes ({comparison['dct']['capacity_kb']:.1f} KB)") click.echo(f" | Capacity: {comparison['dct']['capacity_bytes']:,} bytes ({comparison['dct']['capacity_kb']:.1f} KB)")
click.echo(f" | Ratio: {comparison['dct']['ratio_vs_lsb']:.1f}% of LSB capacity") click.echo(f" | Ratio: {comparison['dct']['ratio_vs_lsb']:.1f}% of LSB capacity")
if comparison['dct']['available']: if comparison['dct']['available']:
click.echo(f" | Status: [OK] Available") click.echo(f" | Status: Available")
click.echo(f" | Formats: PNG (lossless), JPEG (smaller)") click.echo(f" | Formats: PNG (lossless), JPEG (smaller)")
click.echo(f" | Colors: Grayscale (default), Color (v3.0.1)") click.echo(f" | Colors: Grayscale (default), Color (v3.0.1)")
else: else:
click.secho(f" | Status: [X] Requires scipy (pip install scipy)", fg='yellow') click.secho(f" | Status: Requires scipy (pip install scipy)", fg='yellow')
click.echo(" |") click.echo(" |")
# Payload check # Payload check
@@ -862,8 +853,8 @@ def compare(image, payload_size, as_json):
fits_lsb = payload_size <= comparison['lsb']['capacity_bytes'] fits_lsb = payload_size <= comparison['lsb']['capacity_bytes']
fits_dct = payload_size <= comparison['dct']['capacity_bytes'] fits_dct = payload_size <= comparison['dct']['capacity_bytes']
lsb_icon = "[OK]" if fits_lsb else "[X]" lsb_icon = "" if fits_lsb else ""
dct_icon = "[OK]" if fits_dct else "[X]" dct_icon = "" if fits_dct else ""
lsb_color = 'green' if fits_lsb else 'red' lsb_color = 'green' if fits_lsb else 'red'
dct_color = 'green' if fits_dct else 'red' dct_color = 'green' if fits_dct else 'red'
@@ -884,7 +875,7 @@ def compare(image, payload_size, as_json):
elif fits_lsb: elif fits_lsb:
click.echo(" LSB mode (payload too large for DCT)") click.echo(" LSB mode (payload too large for DCT)")
else: else:
click.secho(" [X] Payload too large for both modes!", fg='red') click.secho(" Payload too large for both modes!", fg='red')
else: else:
click.echo(" LSB for larger payloads, DCT for better stealth") click.echo(" LSB for larger payloads, DCT for better stealth")
click.echo(" DCT supports color output with --dct-color color") click.echo(" DCT supports color output with --dct-color color")
@@ -931,7 +922,7 @@ def strip_metadata_cmd(image, output, output_format, quiet):
out_path.write_bytes(clean_data) out_path.write_bytes(clean_data)
if not quiet: if not quiet:
click.secho("[OK] Metadata stripped", fg='green') click.secho(" Metadata stripped", fg='green')
click.echo(f" Input: {image} ({original_size:,} bytes)") click.echo(f" Input: {image} ({original_size:,} bytes)")
click.echo(f" Output: {out_path} ({len(clean_data):,} bytes)") click.echo(f" Output: {out_path} ({len(clean_data):,} bytes)")
@@ -951,12 +942,12 @@ def modes():
Displays which modes are available and their characteristics. Displays which modes are available and their characteristics.
""" """
click.echo() click.echo()
click.secho("=== Stegasoo Embedding Modes ===", fg='cyan', bold=True) click.secho("=== Stegasoo Embedding Modes (v3.2.0) ===", fg='cyan', bold=True)
click.echo() click.echo()
# LSB Mode # LSB Mode
click.secho(" LSB Mode (Spatial LSB)", fg='green', bold=True) click.secho(" LSB Mode (Spatial LSB)", fg='green', bold=True)
click.echo(" Status: [OK] Always available") click.echo(" Status: Always available")
click.echo(" Output: PNG/BMP (full color)") click.echo(" Output: PNG/BMP (full color)")
click.echo(" Capacity: ~375 KB per megapixel") click.echo(" Capacity: ~375 KB per megapixel")
click.echo(" Use case: Larger payloads, color preservation") click.echo(" Use case: Larger payloads, color preservation")
@@ -966,16 +957,16 @@ def modes():
# DCT Mode # DCT Mode
click.secho(" DCT Mode (Frequency Domain)", fg='blue', bold=True) click.secho(" DCT Mode (Frequency Domain)", fg='blue', bold=True)
if has_dct_support(): if has_dct_support():
click.echo(" Status: [OK] Available") click.echo(" Status: Available")
else: else:
click.secho(" Status: [X] Requires scipy", fg='yellow') click.secho(" Status: Requires scipy", fg='yellow')
click.echo(" Install: pip install scipy") click.echo(" Install: pip install scipy")
click.echo(" Capacity: ~75 KB per megapixel (~20% of LSB)") click.echo(" Capacity: ~75 KB per megapixel (~20% of LSB)")
click.echo(" Use case: Better stealth, frequency domain hiding") click.echo(" Use case: Better stealth, frequency domain hiding")
click.echo(" CLI flag: --mode dct") click.echo(" CLI flag: --mode dct")
click.echo() click.echo()
# DCT Options (v3.0.1) # DCT Options
click.secho(" DCT Options (v3.0.1)", fg='magenta', bold=True) click.secho(" DCT Options (v3.0.1)", fg='magenta', bold=True)
click.echo(" Output format:") click.echo(" Output format:")
click.echo(" --dct-format png Lossless, larger file (default)") click.echo(" --dct-format png Lossless, larger file (default)")
@@ -986,6 +977,13 @@ def modes():
click.echo(" --dct-color color Preserves original colors") click.echo(" --dct-color color Preserves original colors")
click.echo() click.echo()
# v3.2.0 Note
click.secho(" v3.2.0 Changes:", fg='cyan', bold=True)
click.echo(" ✓ No date parameters needed")
click.echo(" ✓ Single passphrase (no daily rotation)")
click.echo(" ✓ True asynchronous communications")
click.echo()
# Examples # Examples
click.secho(" Examples:", dim=True) click.secho(" Examples:", dim=True)
click.echo(" # Traditional DCT (grayscale PNG)") click.echo(" # Traditional DCT (grayscale PNG)")

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. 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.
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 import os
@@ -12,14 +18,18 @@ from pathlib import Path
# VERSION # VERSION
# ============================================================================ # ============================================================================
__version__ = "3.1.0" __version__ = "3.2.0"
# ============================================================================ # ============================================================================
# FILE FORMAT # FILE FORMAT
# ============================================================================ # ============================================================================
MAGIC_HEADER = b'\x89ST3' 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 type markers
PAYLOAD_TEXT = 0x01 PAYLOAD_TEXT = 0x01
@@ -46,8 +56,14 @@ PBKDF2_ITERATIONS = 600000
# ============================================================================ # ============================================================================
MAX_IMAGE_PIXELS = 24_000_000 # ~24 megapixels 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_SIZE = 250_000 # 250 KB (text messages)
MAX_MESSAGE_CHARS = 250_000 # Alias for clarity in templates 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 MAX_FILENAME_LENGTH = 255 # Max filename length to store
# File size limits # File size limits
@@ -60,10 +76,17 @@ MIN_PIN_LENGTH = 6
MAX_PIN_LENGTH = 9 MAX_PIN_LENGTH = 9
DEFAULT_PIN_LENGTH = 6 DEFAULT_PIN_LENGTH = 6
# Phrase configuration # Passphrase configuration (v3.2.0: renamed from PHRASE to PASSPHRASE)
MIN_PHRASE_WORDS = 3 # Increased defaults to compensate for removed date entropy (~33 bits)
MAX_PHRASE_WORDS = 12 MIN_PASSPHRASE_WORDS = 3
DEFAULT_PHRASE_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 # RSA configuration
MIN_RSA_BITS = 2048 MIN_RSA_BITS = 2048
@@ -97,8 +120,11 @@ ALLOWED_KEY_EXTENSIONS = {'pem', 'key'}
# Lossless image formats (safe for steganography) # Lossless image formats (safe for steganography)
LOSSLESS_FORMATS = {'PNG', 'BMP', 'TIFF'} 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') 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 # Embedding modes
@@ -200,6 +226,10 @@ DCT_STEP_SIZE = 8 # QIM quantization step
# Valid embedding modes # Valid embedding modes
VALID_EMBED_MODES = {EMBED_MODE_LSB, EMBED_MODE_DCT} 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: 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. Key derivation, encryption, and decryption using AES-256-GCM.
Supports both text messages and binary file payloads. 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 import io
@@ -63,8 +70,7 @@ def hash_photo(image_data: bytes) -> bytes:
def derive_hybrid_key( def derive_hybrid_key(
photo_data: bytes, photo_data: bytes,
day_phrase: str, passphrase: str,
date_str: str,
salt: bytes, salt: bytes,
pin: str = "", pin: str = "",
rsa_key_data: Optional[bytes] = None rsa_key_data: Optional[bytes] = None
@@ -74,18 +80,19 @@ def derive_hybrid_key(
Combines: Combines:
- Photo hash (something you have) - Photo hash (something you have)
- Day phrase (something you know, rotates daily) - Passphrase (something you know)
- PIN (something you know, static) - PIN (something you know, static)
- RSA key (something you have) - RSA key (something you have)
- Date (automatic rotation)
- Salt (random per message) - Salt (random per message)
Uses Argon2id if available, falls back to PBKDF2. 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: Args:
photo_data: Reference photo bytes photo_data: Reference photo bytes
day_phrase: The day's phrase passphrase: Shared passphrase (recommend 4+ words)
date_str: Date string (YYYY-MM-DD)
salt: Random salt for this message salt: Random salt for this message
pin: Optional static PIN pin: Optional static PIN
rsa_key_data: Optional RSA key bytes rsa_key_data: Optional RSA key bytes
@@ -101,9 +108,8 @@ def derive_hybrid_key(
key_material = ( key_material = (
photo_hash + photo_hash +
day_phrase.lower().encode() + passphrase.lower().encode() +
pin.encode() + pin.encode() +
date_str.encode() +
salt salt
) )
@@ -139,8 +145,7 @@ def derive_hybrid_key(
def derive_pixel_key( def derive_pixel_key(
photo_data: bytes, photo_data: bytes,
day_phrase: str, passphrase: str,
date_str: str,
pin: str = "", pin: str = "",
rsa_key_data: Optional[bytes] = None rsa_key_data: Optional[bytes] = None
) -> bytes: ) -> bytes:
@@ -150,10 +155,11 @@ def derive_pixel_key(
This key determines which pixels are used for embedding, This key determines which pixels are used for embedding,
making the message location unpredictable without the correct inputs. making the message location unpredictable without the correct inputs.
NOTE: v3.2.0 removed date dependency.
Args: Args:
photo_data: Reference photo bytes photo_data: Reference photo bytes
day_phrase: The day's phrase passphrase: Shared passphrase
date_str: Date string (YYYY-MM-DD)
pin: Optional static PIN pin: Optional static PIN
rsa_key_data: Optional RSA key bytes rsa_key_data: Optional RSA key bytes
@@ -164,9 +170,8 @@ def derive_pixel_key(
material = ( material = (
photo_hash + photo_hash +
day_phrase.lower().encode() + passphrase.lower().encode() +
pin.encode() + pin.encode()
date_str.encode()
) )
if rsa_key_data: if rsa_key_data:
@@ -282,19 +287,16 @@ def _unpack_payload(data: bytes) -> DecodeResult:
def encrypt_message( def encrypt_message(
message: Union[str, bytes, FilePayload], message: Union[str, bytes, FilePayload],
photo_data: bytes, photo_data: bytes,
day_phrase: str, passphrase: str,
date_str: str,
pin: str = "", pin: str = "",
rsa_key_data: Optional[bytes] = None rsa_key_data: Optional[bytes] = None
) -> bytes: ) -> bytes:
""" """
Encrypt message or file using AES-256-GCM with hybrid key derivation. 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) - Magic header (4 bytes)
- Version (1 byte) - Version (1 byte) = 4
- Date length (1 byte)
- Date string (variable)
- Salt (32 bytes) - Salt (32 bytes)
- IV (12 bytes) - IV (12 bytes)
- Auth tag (16 bytes) - Auth tag (16 bytes)
@@ -303,8 +305,7 @@ def encrypt_message(
Args: Args:
message: Message string, raw bytes, or FilePayload to encrypt message: Message string, raw bytes, or FilePayload to encrypt
photo_data: Reference photo bytes photo_data: Reference photo bytes
day_phrase: The day's phrase passphrase: Shared passphrase (recommend 4+ words for good entropy)
date_str: Date string (YYYY-MM-DD)
pin: Optional static PIN pin: Optional static PIN
rsa_key_data: Optional RSA key bytes rsa_key_data: Optional RSA key bytes
@@ -316,7 +317,7 @@ def encrypt_message(
""" """
try: try:
salt = secrets.token_bytes(SALT_SIZE) 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) iv = secrets.token_bytes(IV_SIZE)
# Pack payload with type marker # Pack payload with type marker
@@ -335,13 +336,10 @@ def encrypt_message(
encryptor.authenticate_additional_data(MAGIC_HEADER + bytes([FORMAT_VERSION])) encryptor.authenticate_additional_data(MAGIC_HEADER + bytes([FORMAT_VERSION]))
ciphertext = encryptor.update(padded_message) + encryptor.finalize() ciphertext = encryptor.update(padded_message) + encryptor.finalize()
date_bytes = date_str.encode() # v3.2.0: Simplified header without date
return ( return (
MAGIC_HEADER + MAGIC_HEADER +
bytes([FORMAT_VERSION]) + bytes([FORMAT_VERSION]) +
bytes([len(date_bytes)]) +
date_bytes +
salt + salt +
iv + iv +
encryptor.tag + encryptor.tag +
@@ -356,13 +354,16 @@ def parse_header(encrypted_data: bytes) -> Optional[dict]:
""" """
Parse the header from encrypted data. Parse the header from encrypted data.
v3.2.0: No date field in header.
Args: Args:
encrypted_data: Raw encrypted bytes encrypted_data: Raw encrypted bytes
Returns: 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 return None
try: try:
@@ -370,10 +371,7 @@ def parse_header(encrypted_data: bytes) -> Optional[dict]:
if version != FORMAT_VERSION: if version != FORMAT_VERSION:
return None return None
date_len = encrypted_data[5] offset = 5
date_str = encrypted_data[6:6 + date_len].decode()
offset = 6 + date_len
salt = encrypted_data[offset:offset + SALT_SIZE] salt = encrypted_data[offset:offset + SALT_SIZE]
offset += SALT_SIZE offset += SALT_SIZE
iv = encrypted_data[offset:offset + IV_SIZE] iv = encrypted_data[offset:offset + IV_SIZE]
@@ -383,7 +381,6 @@ def parse_header(encrypted_data: bytes) -> Optional[dict]:
ciphertext = encrypted_data[offset:] ciphertext = encrypted_data[offset:]
return { return {
'date': date_str,
'salt': salt, 'salt': salt,
'iv': iv, 'iv': iv,
'tag': tag, 'tag': tag,
@@ -396,17 +393,17 @@ def parse_header(encrypted_data: bytes) -> Optional[dict]:
def decrypt_message( def decrypt_message(
encrypted_data: bytes, encrypted_data: bytes,
photo_data: bytes, photo_data: bytes,
day_phrase: str, passphrase: str,
pin: str = "", pin: str = "",
rsa_key_data: Optional[bytes] = None rsa_key_data: Optional[bytes] = None
) -> DecodeResult: ) -> DecodeResult:
""" """
Decrypt message using the embedded date from the header. Decrypt message (v3.2.0 - no date needed).
Args: Args:
encrypted_data: Encrypted message bytes encrypted_data: Encrypted message bytes
photo_data: Reference photo bytes photo_data: Reference photo bytes
day_phrase: The day's phrase (must match encoding day) passphrase: Shared passphrase
pin: Optional static PIN pin: Optional static PIN
rsa_key_data: Optional RSA key bytes rsa_key_data: Optional RSA key bytes
@@ -423,7 +420,7 @@ def decrypt_message(
try: try:
key = derive_hybrid_key( 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( cipher = Cipher(
@@ -439,20 +436,21 @@ def decrypt_message(
payload_data = padded_plaintext[:original_length] payload_data = padded_plaintext[:original_length]
result = _unpack_payload(payload_data) result = _unpack_payload(payload_data)
result.date_encoded = header['date']
# Note: No date_encoded field in v3.2.0
return result return result
except Exception as e: except Exception as e:
raise DecryptionError( 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 ) from e
def decrypt_message_text( def decrypt_message_text(
encrypted_data: bytes, encrypted_data: bytes,
photo_data: bytes, photo_data: bytes,
day_phrase: str, passphrase: str,
pin: str = "", pin: str = "",
rsa_key_data: Optional[bytes] = None rsa_key_data: Optional[bytes] = None
) -> str: ) -> str:
@@ -464,7 +462,7 @@ def decrypt_message_text(
Args: Args:
encrypted_data: Encrypted message bytes encrypted_data: Encrypted message bytes
photo_data: Reference photo bytes photo_data: Reference photo bytes
day_phrase: The day's phrase passphrase: Shared passphrase
pin: Optional static PIN pin: Optional static PIN
rsa_key_data: Optional RSA key bytes rsa_key_data: Optional RSA key bytes
@@ -474,7 +472,7 @@ def decrypt_message_text(
Raises: Raises:
DecryptionError: If decryption fails or content is a file 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.is_file:
if result.file_data: if result.file_data:
@@ -490,22 +488,6 @@ def decrypt_message_text(
return result.message or "" 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: def has_argon2() -> bool:
"""Check if Argon2 is available.""" """Check if Argon2 is available."""
return HAS_ARGON2 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. 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 dataclasses import dataclass, field
from datetime import date from datetime import date
from typing import Optional, Union from typing import Optional, Union, List
@dataclass @dataclass
class Credentials: 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 pin: Optional[str] = None
rsa_key_pem: Optional[str] = None rsa_key_pem: Optional[str] = None
rsa_bits: Optional[int] = 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 @property
def phrase_entropy(self) -> int: def passphrase_entropy(self) -> int:
"""Entropy in bits from phrases (~11 bits per BIP-39 word).""" """Entropy in bits from passphrase (~11 bits per BIP-39 word)."""
return self.words_per_phrase * 11 return self.words_per_passphrase * 11
@property @property
def pin_entropy(self) -> int: def pin_entropy(self) -> int:
@@ -40,7 +54,13 @@ class Credentials:
@property @property
def total_entropy(self) -> int: def total_entropy(self) -> int:
"""Total entropy in bits (excluding reference photo).""" """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 @dataclass
@@ -70,30 +90,33 @@ class FilePayload:
@dataclass @dataclass
class EncodeInput: 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 message: Union[str, bytes, FilePayload] # Text, raw bytes, or file
reference_photo: bytes reference_photo: bytes
carrier_image: bytes carrier_image: bytes
day_phrase: str passphrase: str # Renamed from day_phrase
pin: str = "" pin: str = ""
rsa_key_data: Optional[bytes] = None rsa_key_data: Optional[bytes] = None
rsa_password: Optional[str] = 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 @dataclass
class EncodeResult: 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 stego_image: bytes
filename: str filename: str
pixels_modified: int pixels_modified: int
total_pixels: int total_pixels: int
capacity_used: float # 0.0 - 1.0 capacity_used: float # 0.0 - 1.0
date_used: str date_used: Optional[str] = None # Cosmetic only (for filename organization)
@property @property
def capacity_percent(self) -> float: def capacity_percent(self) -> float:
@@ -103,10 +126,14 @@ class EncodeResult:
@dataclass @dataclass
class DecodeInput: 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 stego_image: bytes
reference_photo: bytes reference_photo: bytes
day_phrase: str passphrase: str # Renamed from day_phrase
pin: str = "" pin: str = ""
rsa_key_data: Optional[bytes] = None rsa_key_data: Optional[bytes] = None
rsa_password: Optional[str] = None rsa_password: Optional[str] = None
@@ -114,13 +141,17 @@ class DecodeInput:
@dataclass @dataclass
class DecodeResult: 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' payload_type: str # 'text' or 'file'
message: Optional[str] = None # For text payloads message: Optional[str] = None # For text payloads
file_data: Optional[bytes] = None # For file payloads file_data: Optional[bytes] = None # For file payloads
filename: Optional[str] = None # Original filename for file payloads filename: Optional[str] = None # Original filename for file payloads
mime_type: Optional[str] = None # MIME type hint 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 @property
def is_file(self) -> bool: def is_file(self) -> bool:
@@ -165,13 +196,77 @@ class ValidationResult:
is_valid: bool is_valid: bool
error_message: str = "" error_message: str = ""
details: dict = field(default_factory=dict) details: dict = field(default_factory=dict)
warning: Optional[str] = None # v3.2.0: Added for passphrase length warnings
@classmethod @classmethod
def ok(cls, **details) -> 'ValidationResult': def ok(cls, warning: Optional[str] = None, **details) -> 'ValidationResult':
"""Create a successful validation result.""" """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 @classmethod
def error(cls, message: str, **details) -> 'ValidationResult': def error(cls, message: str, **details) -> 'ValidationResult':
"""Create a failed validation result.""" """Create a failed validation result."""
return cls(is_valid=False, error_message=message, details=details) 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. 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 import io
@@ -14,6 +19,8 @@ from .constants import (
MAX_MESSAGE_SIZE, MAX_FILE_PAYLOAD_SIZE, MAX_IMAGE_PIXELS, MAX_FILE_SIZE, MAX_MESSAGE_SIZE, MAX_FILE_PAYLOAD_SIZE, MAX_IMAGE_PIXELS, MAX_FILE_SIZE,
MIN_RSA_BITS, MIN_KEY_PASSWORD_LENGTH, MIN_RSA_BITS, MIN_KEY_PASSWORD_LENGTH,
ALLOWED_IMAGE_EXTENSIONS, ALLOWED_KEY_EXTENSIONS, 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 .models import ValidationResult, FilePayload
from .exceptions import ( from .exceptions import (
@@ -325,55 +332,110 @@ def validate_key_password(password: str) -> ValidationResult:
return ValidationResult.ok(length=len(password)) 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: Args:
phrase: Phrase string passphrase: Passphrase string
Returns: Returns:
ValidationResult with word_count ValidationResult with word_count and optional warning
""" """
if not phrase or not phrase.strip(): if not passphrase or not passphrase.strip():
return ValidationResult.error("Day phrase is required") 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)) 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: Args:
date_str: Date string mode: Embedding mode string
Returns: Returns:
ValidationResult ValidationResult
""" """
if not date_str: valid_modes = {EMBED_MODE_LSB, EMBED_MODE_DCT, EMBED_MODE_AUTO}
return ValidationResult.error("Date is required")
if len(date_str) != 10: if mode not in valid_modes:
return ValidationResult.error("Date must be in YYYY-MM-DD format") return ValidationResult.error(
f"Invalid embed_mode: '{mode}'. Valid options: {', '.join(sorted(valid_modes))}"
)
if date_str[4] != '-' or date_str[7] != '-': return ValidationResult.ok(mode=mode)
return ValidationResult.error("Date must be in YYYY-MM-DD format")
try:
year = int(date_str[0:4])
month = int(date_str[5:7])
day = int(date_str[8:10])
if not (1 <= month <= 12 and 1 <= day <= 31 and 2000 <= year <= 2100): def validate_dct_output_format(format_str: str) -> ValidationResult:
return ValidationResult.error("Invalid date values") """
Validate DCT output format.
return ValidationResult.ok(year=year, month=month, day=day) Args:
format_str: Output format ('png' or 'jpeg')
except ValueError: Returns:
return ValidationResult.error("Date must contain valid numbers") 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')
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())
# ============================================================================ # ============================================================================