3.2.0 Big revamp
This commit is contained in:
500
frontends/api/API_UPDATE_SUMMARY_V3.2.0.md
Normal file
500
frontends/api/API_UPDATE_SUMMARY_V3.2.0.md
Normal 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!
|
||||||
@@ -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)",
|
||||||
),
|
),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,13 +201,16 @@ 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:
|
||||||
click.echo(f" RSA entropy: {creds.rsa_entropy} bits")
|
click.echo(f" RSA entropy: {creds.rsa_entropy} bits")
|
||||||
click.echo(f" Combined: {creds.total_entropy} bits")
|
click.echo(f" Combined: {creds.total_entropy} bits")
|
||||||
click.secho(f" + photo entropy: 80-256 bits", dim=True)
|
click.secho(f" + photo entropy: 80-256 bits", dim=True)
|
||||||
|
click.echo()
|
||||||
|
|
||||||
|
click.secho("NOTE: v3.2.0 removed date dependency - use this passphrase anytime!", fg='cyan')
|
||||||
click.echo()
|
click.echo()
|
||||||
|
|
||||||
except Exception as e:
|
except Exception as 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)")
|
||||||
|
|||||||
374
src/stegasoo/COMPLETE_CHANGE_SUMMARY_V3.2.0.md
Normal file
374
src/stegasoo/COMPLETE_CHANGE_SUMMARY_V3.2.0.md
Normal 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
448
src/stegasoo/channel.py
Normal 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)
|
||||||
@@ -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:
|
||||||
"""
|
"""
|
||||||
|
|||||||
@@ -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
208
src/stegasoo/decode.py
Normal 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
234
src/stegasoo/encode.py
Normal 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
167
src/stegasoo/generate.py
Normal 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
166
src/stegasoo/image_utils.py
Normal 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,
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
|||||||
@@ -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())
|
||||||
|
|
||||||
|
|
||||||
# ============================================================================
|
# ============================================================================
|
||||||
|
|||||||
Reference in New Issue
Block a user