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
|
||||
"""
|
||||
Stegasoo REST API (v3.0.1)
|
||||
Stegasoo REST API (v3.2.0)
|
||||
|
||||
FastAPI-based REST API for steganography operations.
|
||||
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.1: DCT color mode and JPEG output format.
|
||||
"""
|
||||
@@ -26,13 +33,12 @@ import stegasoo
|
||||
from stegasoo import (
|
||||
encode, decode, generate_credentials,
|
||||
validate_image, calculate_capacity,
|
||||
get_day_from_date,
|
||||
DAY_NAMES, __version__,
|
||||
__version__,
|
||||
StegasooError, DecryptionError, CapacityError,
|
||||
has_argon2,
|
||||
FilePayload,
|
||||
MAX_FILE_PAYLOAD_SIZE,
|
||||
# NEW in v3.0 - Embedding modes
|
||||
# Embedding modes
|
||||
EMBED_MODE_LSB,
|
||||
EMBED_MODE_DCT,
|
||||
EMBED_MODE_AUTO,
|
||||
@@ -43,7 +49,8 @@ from stegasoo import (
|
||||
)
|
||||
from stegasoo.constants import (
|
||||
MIN_PIN_LENGTH, MAX_PIN_LENGTH,
|
||||
MIN_PHRASE_WORDS, MAX_PHRASE_WORDS,
|
||||
MIN_PASSPHRASE_WORDS, MAX_PASSPHRASE_WORDS,
|
||||
DEFAULT_PASSPHRASE_WORDS,
|
||||
VALID_RSA_SIZES,
|
||||
)
|
||||
|
||||
@@ -68,6 +75,12 @@ app = FastAPI(
|
||||
description="""
|
||||
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)
|
||||
|
||||
- **LSB mode** (default): Spatial LSB embedding, full color output, higher capacity
|
||||
@@ -105,25 +118,35 @@ class GenerateRequest(BaseModel):
|
||||
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)
|
||||
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):
|
||||
phrases: dict[str, str]
|
||||
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"
|
||||
)
|
||||
|
||||
|
||||
class EncodeRequest(BaseModel):
|
||||
message: str
|
||||
reference_photo_base64: str
|
||||
carrier_image_base64: str
|
||||
day_phrase: 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: Optional[str] = None
|
||||
# date_str removed in v3.2.0
|
||||
embed_mode: EmbedModeType = Field(
|
||||
default="lsb",
|
||||
description="Embedding mode: 'lsb' (default, color) or 'dct' (requires scipy)"
|
||||
@@ -146,11 +169,11 @@ class EncodeFileRequest(BaseModel):
|
||||
mime_type: Optional[str] = None
|
||||
reference_photo_base64: str
|
||||
carrier_image_base64: str
|
||||
day_phrase: 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: Optional[str] = None
|
||||
# date_str removed in v3.2.0
|
||||
embed_mode: EmbedModeType = Field(
|
||||
default="lsb",
|
||||
description="Embedding mode: 'lsb' (default, color) or 'dct' (requires scipy)"
|
||||
@@ -170,8 +193,6 @@ class EncodeResponse(BaseModel):
|
||||
stego_image_base64: str
|
||||
filename: str
|
||||
capacity_used_percent: float
|
||||
date_used: str
|
||||
day_of_week: str
|
||||
embed_mode: str = Field(description="Embedding mode used: 'lsb' or 'dct'")
|
||||
# NEW in v3.0.1
|
||||
output_format: str = Field(
|
||||
@@ -182,12 +203,21 @@ class EncodeResponse(BaseModel):
|
||||
default="color",
|
||||
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):
|
||||
stego_image_base64: str
|
||||
reference_photo_base64: str
|
||||
day_phrase: 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
|
||||
@@ -268,7 +298,6 @@ class StatusResponse(BaseModel):
|
||||
has_argon2: bool
|
||||
has_qrcode_read: bool
|
||||
has_dct: bool
|
||||
day_names: list[str]
|
||||
max_payload_kb: int
|
||||
available_modes: list[str]
|
||||
# NEW in v3.0.1
|
||||
@@ -276,6 +305,10 @@ class StatusResponse(BaseModel):
|
||||
default=None,
|
||||
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):
|
||||
@@ -330,10 +363,15 @@ async def root():
|
||||
has_argon2=has_argon2(),
|
||||
has_qrcode_read=HAS_QR_READ,
|
||||
has_dct=has_dct_support(),
|
||||
day_names=list(DAY_NAMES),
|
||||
max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024,
|
||||
available_modes=available_modes,
|
||||
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.
|
||||
|
||||
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:
|
||||
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,
|
||||
pin_length=request.pin_length,
|
||||
rsa_bits=request.rsa_bits,
|
||||
words_per_phrase=request.words_per_phrase
|
||||
words_per_passphrase=request.words_per_passphrase
|
||||
)
|
||||
|
||||
return GenerateResponse(
|
||||
phrases=creds.phrases,
|
||||
passphrase=creds.passphrase, # v3.2.0: Single passphrase
|
||||
pin=creds.pin,
|
||||
rsa_key_pem=creds.rsa_key_pem,
|
||||
entropy={
|
||||
"phrase": creds.phrase_entropy,
|
||||
"passphrase": creds.passphrase_entropy,
|
||||
"pin": creds.pin_entropy,
|
||||
"rsa": creds.rsa_entropy,
|
||||
"total": creds.total_entropy
|
||||
}
|
||||
},
|
||||
phrases=None # Legacy field removed
|
||||
)
|
||||
except Exception as 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.
|
||||
|
||||
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.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
|
||||
)
|
||||
|
||||
# v3.2.0: No date_str parameter
|
||||
result = encode(
|
||||
message=request.message,
|
||||
reference_photo=ref_photo,
|
||||
carrier_image=carrier,
|
||||
day_phrase=request.day_phrase,
|
||||
passphrase=request.passphrase, # v3.2.0: Renamed from day_phrase
|
||||
pin=request.pin,
|
||||
rsa_key_data=rsa_key,
|
||||
rsa_password=request.rsa_password,
|
||||
date_str=request.date_str,
|
||||
# date_str removed in v3.2.0
|
||||
embed_mode=request.embed_mode,
|
||||
**dct_params, # NEW in v3.0.1
|
||||
**dct_params,
|
||||
)
|
||||
|
||||
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(
|
||||
request.embed_mode,
|
||||
@@ -618,11 +662,11 @@ async def api_encode(request: EncodeRequest):
|
||||
stego_image_base64=stego_b64,
|
||||
filename=result.filename,
|
||||
capacity_used_percent=result.capacity_percent,
|
||||
date_used=result.date_used,
|
||||
day_of_week=day_of_week,
|
||||
embed_mode=request.embed_mode,
|
||||
output_format=output_format,
|
||||
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:
|
||||
@@ -640,6 +684,8 @@ async def api_encode_file(request: EncodeFileRequest):
|
||||
|
||||
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.1: Supports dct_output_format and dct_color_mode.
|
||||
"""
|
||||
@@ -666,21 +712,21 @@ async def api_encode_file(request: EncodeFileRequest):
|
||||
request.dct_color_mode
|
||||
)
|
||||
|
||||
# v3.2.0: No date_str parameter
|
||||
result = encode(
|
||||
message=payload,
|
||||
reference_photo=ref_photo,
|
||||
carrier_image=carrier,
|
||||
day_phrase=request.day_phrase,
|
||||
passphrase=request.passphrase, # v3.2.0: Renamed from day_phrase
|
||||
pin=request.pin,
|
||||
rsa_key_data=rsa_key,
|
||||
rsa_password=request.rsa_password,
|
||||
date_str=request.date_str,
|
||||
# date_str removed in v3.2.0
|
||||
embed_mode=request.embed_mode,
|
||||
**dct_params, # NEW in v3.0.1
|
||||
**dct_params,
|
||||
)
|
||||
|
||||
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(
|
||||
request.embed_mode,
|
||||
@@ -692,11 +738,11 @@ async def api_encode_file(request: EncodeFileRequest):
|
||||
stego_image_base64=stego_b64,
|
||||
filename=result.filename,
|
||||
capacity_used_percent=result.capacity_percent,
|
||||
date_used=result.date_used,
|
||||
day_of_week=day_of_week,
|
||||
embed_mode=request.embed_mode,
|
||||
output_format=output_format,
|
||||
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:
|
||||
@@ -718,6 +764,8 @@ async def api_decode(request: DecodeRequest):
|
||||
|
||||
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').
|
||||
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)
|
||||
rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None
|
||||
|
||||
# v3.2.0: No date_str parameter
|
||||
result = decode(
|
||||
stego_image=stego,
|
||||
reference_photo=ref_photo,
|
||||
day_phrase=request.day_phrase,
|
||||
passphrase=request.passphrase, # v3.2.0: Renamed from day_phrase
|
||||
pin=request.pin,
|
||||
rsa_key_data=rsa_key,
|
||||
rsa_password=request.rsa_password,
|
||||
@@ -770,7 +819,7 @@ async def api_decode(request: DecodeRequest):
|
||||
|
||||
@app.post("/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(...),
|
||||
carrier: UploadFile = File(...),
|
||||
message: str = Form(""),
|
||||
@@ -779,7 +828,7 @@ async def api_encode_multipart(
|
||||
rsa_key: Optional[UploadFile] = File(None),
|
||||
rsa_key_qr: Optional[UploadFile] = File(None),
|
||||
rsa_password: str = Form(""),
|
||||
date_str: str = Form(""),
|
||||
# date_str removed in v3.2.0
|
||||
embed_mode: str = Form("lsb"),
|
||||
# NEW in v3.0.1
|
||||
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).
|
||||
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.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
|
||||
dct_params = _get_dct_params(embed_mode, dct_output_format, dct_color_mode)
|
||||
|
||||
# v3.2.0: No date_str parameter
|
||||
result = encode(
|
||||
message=payload,
|
||||
reference_photo=ref_data,
|
||||
carrier_image=carrier_data,
|
||||
day_phrase=day_phrase,
|
||||
passphrase=passphrase, # v3.2.0: Renamed from day_phrase
|
||||
pin=pin,
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=effective_password,
|
||||
date_str=date_str if date_str else None,
|
||||
# date_str removed in v3.2.0
|
||||
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(
|
||||
embed_mode, dct_output_format, dct_color_mode
|
||||
)
|
||||
@@ -872,12 +923,11 @@ async def api_encode_multipart(
|
||||
media_type=mime_type,
|
||||
headers={
|
||||
"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-Embed-Mode": embed_mode,
|
||||
"X-Stegasoo-Output-Format": output_format, # NEW in v3.0.1
|
||||
"X-Stegasoo-Color-Mode": color_mode, # NEW in v3.0.1
|
||||
"X-Stegasoo-Output-Format": output_format,
|
||||
"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)
|
||||
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(...),
|
||||
stego_image: UploadFile = File(...),
|
||||
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).
|
||||
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').
|
||||
|
||||
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
|
||||
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(
|
||||
stego_image=stego_data,
|
||||
reference_photo=ref_data,
|
||||
day_phrase=day_phrase,
|
||||
passphrase=passphrase, # v3.2.0: Renamed from day_phrase
|
||||
pin=pin,
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=effective_password,
|
||||
@@ -1022,7 +1075,7 @@ async def api_image_info(
|
||||
capacity_bytes=comparison['dct']['capacity_bytes'],
|
||||
capacity_kb=round(comparison['dct']['capacity_kb'], 1),
|
||||
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
|
||||
"""
|
||||
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:
|
||||
stegasoo generate [OPTIONS]
|
||||
@@ -10,10 +15,6 @@ Usage:
|
||||
stegasoo info [OPTIONS]
|
||||
stegasoo compare [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
|
||||
@@ -31,13 +32,13 @@ from stegasoo import (
|
||||
generate_credentials,
|
||||
export_rsa_key_pem, load_rsa_key,
|
||||
validate_image, calculate_capacity,
|
||||
get_day_from_date, parse_date_from_filename,
|
||||
DAY_NAMES, __version__,
|
||||
parse_date_from_filename, # Keep for filename parsing only
|
||||
__version__,
|
||||
StegasooError, DecryptionError, ExtractionError,
|
||||
FilePayload,
|
||||
will_fit,
|
||||
strip_image_metadata,
|
||||
# NEW in v3.0 - Embedding modes
|
||||
# Embedding modes
|
||||
EMBED_MODE_LSB,
|
||||
EMBED_MODE_DCT,
|
||||
EMBED_MODE_AUTO,
|
||||
@@ -79,16 +80,22 @@ def cli():
|
||||
|
||||
\b
|
||||
- Reference photo (something you have)
|
||||
- Daily passphrase (something you know)
|
||||
- Passphrase (something you know)
|
||||
- Static PIN or RSA key (additional security)
|
||||
|
||||
\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
|
||||
- DCT mode: Frequency domain, ~20% capacity, better stealth
|
||||
|
||||
\b
|
||||
DCT Options (v3.0.1):
|
||||
DCT Options:
|
||||
- Color mode: grayscale (default) or color (preserves colors)
|
||||
- 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('--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('--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('--password', '-p', help='Password for RSA key file')
|
||||
@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.
|
||||
|
||||
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.
|
||||
|
||||
v3.2.0: No more daily passphrases - use one strong passphrase!
|
||||
Default increased to 4 words (from 3) for better security.
|
||||
|
||||
\b
|
||||
Examples:
|
||||
stegasoo generate
|
||||
stegasoo generate --words 5
|
||||
stegasoo generate --rsa --rsa-bits 4096
|
||||
stegasoo generate --rsa -o mykey.pem -p "secretpassword"
|
||||
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,
|
||||
pin_length=pin_length,
|
||||
rsa_bits=int(rsa_bits),
|
||||
words_per_phrase=words
|
||||
words_per_passphrase=words
|
||||
)
|
||||
|
||||
if as_json:
|
||||
import json
|
||||
data = {
|
||||
'phrases': creds.phrases,
|
||||
'passphrase': creds.passphrase,
|
||||
'pin': creds.pin,
|
||||
'rsa_key': creds.rsa_key_pem,
|
||||
'entropy': {
|
||||
'phrase': creds.phrase_entropy,
|
||||
'passphrase': creds.passphrase_entropy,
|
||||
'pin': creds.pin_entropy,
|
||||
'rsa': creds.rsa_entropy,
|
||||
'total': creds.total_entropy,
|
||||
@@ -159,7 +170,7 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json):
|
||||
# Pretty output
|
||||
click.echo()
|
||||
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.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.echo()
|
||||
|
||||
click.secho("--- DAILY PHRASES ---", fg='green')
|
||||
for day in DAY_NAMES:
|
||||
phrase = creds.phrases[day]
|
||||
click.echo(f" {day:9} | ", nl=False)
|
||||
click.secho(phrase, fg='bright_white')
|
||||
click.secho("--- PASSPHRASE ---", fg='green')
|
||||
click.secho(f" {creds.passphrase}", fg='bright_white', bold=True)
|
||||
click.echo()
|
||||
|
||||
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.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:
|
||||
click.echo(f" PIN entropy: {creds.pin_entropy} bits")
|
||||
click.echo(f" PIN entropy: {creds.pin_entropy} bits")
|
||||
if creds.rsa_key_pem:
|
||||
click.echo(f" RSA entropy: {creds.rsa_entropy} bits")
|
||||
click.echo(f" Combined: {creds.total_entropy} bits")
|
||||
click.secho(f" + photo entropy: 80-256 bits", dim=True)
|
||||
click.echo(f" RSA entropy: {creds.rsa_entropy} bits")
|
||||
click.echo(f" Combined: {creds.total_entropy} bits")
|
||||
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()
|
||||
|
||||
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-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('--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('--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-password', help='RSA key password (for encrypted .pem files)')
|
||||
@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',
|
||||
help='Embedding mode: lsb (default, color) or dct (requires scipy)')
|
||||
@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',
|
||||
help='DCT color mode: grayscale (default) or color (preserves original colors)')
|
||||
@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,
|
||||
key_password, output, date_str, embed_mode, dct_output_format, dct_color_mode, quiet):
|
||||
def encode_cmd(ref, carrier, message, message_file, embed_file, passphrase, pin, key, key_qr,
|
||||
key_password, output, embed_mode, dct_output_format, dct_color_mode, quiet):
|
||||
"""
|
||||
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).
|
||||
|
||||
v3.2.0: No --date parameter needed! Encode and decode anytime.
|
||||
|
||||
For text messages, use -m or -f or pipe via stdin.
|
||||
For binary files, use -e/--embed-file.
|
||||
RSA key can be provided as a .pem file (--key) or QR code image (--key-qr).
|
||||
|
||||
\b
|
||||
Embedding Modes (v3.0):
|
||||
Embedding Modes:
|
||||
--mode lsb Spatial LSB embedding (default)
|
||||
- Full color output (PNG/BMP)
|
||||
- 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
|
||||
|
||||
\b
|
||||
DCT Options (v3.0.1):
|
||||
DCT Options:
|
||||
--dct-format png Lossless output (default)
|
||||
--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
|
||||
Examples:
|
||||
# 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)
|
||||
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)
|
||||
stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -m "secret" \
|
||||
# DCT mode - color JPEG
|
||||
stegasoo encode -r photo.jpg -c meme.png -p "my strong passphrase" --pin 123456 -m "secret" \
|
||||
--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
|
||||
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()})"
|
||||
click.echo(f"Mode: {mode_desc} ({fit_check['usage_percent']:.1f}% capacity)")
|
||||
|
||||
# v3.2.0: No date_str parameter
|
||||
result = encode(
|
||||
message=payload,
|
||||
reference_photo=ref_photo,
|
||||
carrier_image=carrier_image,
|
||||
day_phrase=phrase,
|
||||
passphrase=passphrase, # Renamed from day_phrase
|
||||
pin=pin or "",
|
||||
rsa_key_data=rsa_key_data,
|
||||
rsa_password=effective_key_password,
|
||||
date_str=date_str,
|
||||
embed_mode=embed_mode,
|
||||
dct_output_format=dct_output_format,
|
||||
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)
|
||||
|
||||
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" Size: {len(result.stego_image):,} bytes")
|
||||
click.echo(f" Capacity used: {result.capacity_percent:.1f}%")
|
||||
click.echo(f" Date: {result.date_used}")
|
||||
if embed_mode == 'dct':
|
||||
color_note = "color preserved" if dct_color_mode == 'color' else "grayscale"
|
||||
format_note = dct_output_format.upper()
|
||||
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:
|
||||
raise click.ClickException(str(e))
|
||||
@@ -414,7 +422,7 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key
|
||||
@cli.command()
|
||||
@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('--phrase', '-p', required=True, help='Day phrase')
|
||||
@click.option('--passphrase', '-p', required=True, help='Passphrase')
|
||||
@click.option('--pin', help='Static PIN')
|
||||
@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')
|
||||
@@ -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')
|
||||
@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')
|
||||
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.
|
||||
|
||||
@@ -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.
|
||||
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
|
||||
created with color mode or grayscale mode - both use the same Y channel.
|
||||
|
||||
\b
|
||||
Extraction Modes (v3.0):
|
||||
Extraction Modes:
|
||||
--mode auto Auto-detect (default) - tries LSB first, then DCT
|
||||
--mode lsb Only try LSB extraction
|
||||
--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
|
||||
Examples:
|
||||
# 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
|
||||
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
|
||||
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
|
||||
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
|
||||
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()
|
||||
stego_image = Path(stego).read_bytes()
|
||||
|
||||
# v3.2.0: No date_str parameter
|
||||
result = decode(
|
||||
stego_image=stego_image,
|
||||
reference_photo=ref_photo,
|
||||
day_phrase=phrase,
|
||||
passphrase=passphrase, # Renamed from day_phrase
|
||||
pin=pin or "",
|
||||
rsa_key_data=rsa_key_data,
|
||||
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)
|
||||
|
||||
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" Size: {len(result.file_data):,} bytes")
|
||||
if result.mime_type:
|
||||
@@ -532,13 +543,13 @@ def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, embed
|
||||
if output:
|
||||
Path(output).write_text(result.message)
|
||||
if not quiet:
|
||||
click.secho("[OK] Decoded successfully!", fg='green')
|
||||
click.secho("✓ Decoded successfully!", fg='green')
|
||||
click.echo(f" Saved to: {output}")
|
||||
else:
|
||||
if quiet:
|
||||
click.echo(result.message)
|
||||
else:
|
||||
click.secho("[OK] Decoded successfully!", fg='green')
|
||||
click.secho("✓ Decoded successfully!", fg='green')
|
||||
click.echo()
|
||||
click.echo(result.message)
|
||||
|
||||
@@ -557,7 +568,7 @@ def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, embed
|
||||
@cli.command()
|
||||
@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('--phrase', '-p', required=True, help='Day phrase')
|
||||
@click.option('--passphrase', '-p', required=True, help='Passphrase')
|
||||
@click.option('--pin', help='Static PIN')
|
||||
@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')
|
||||
@@ -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',
|
||||
help='Extraction mode: auto (default), lsb, or dct')
|
||||
@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.
|
||||
|
||||
@@ -574,11 +585,11 @@ def verify(ref, stego, phrase, pin, key, key_qr, key_password, embed_mode, as_js
|
||||
|
||||
\b
|
||||
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
|
||||
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(
|
||||
stego_image=stego_image,
|
||||
reference_photo=ref_photo,
|
||||
day_phrase=phrase,
|
||||
passphrase=passphrase, # v3.2.0: Renamed from day_phrase
|
||||
pin=pin or "",
|
||||
rsa_key_data=rsa_key_data,
|
||||
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_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:
|
||||
import json
|
||||
output = {
|
||||
@@ -650,19 +657,15 @@ def verify(ref, stego, phrase, pin, key, key_qr, key_password, embed_mode, as_js
|
||||
"stego_file": stego,
|
||||
"payload_type": payload_type,
|
||||
"payload_size": payload_size,
|
||||
"date_encoded": date_encoded,
|
||||
"day_encoded": day_name,
|
||||
}
|
||||
if result.is_file:
|
||||
output["filename"] = result.filename
|
||||
output["mime_type"] = result.mime_type
|
||||
click.echo(json.dumps(output, indent=2))
|
||||
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" Size: {payload_size:,} bytes")
|
||||
if date_encoded:
|
||||
click.echo(f" Encoded: {date_encoded} ({day_name})")
|
||||
|
||||
except (DecryptionError, ExtractionError) as e:
|
||||
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))
|
||||
sys.exit(1)
|
||||
else:
|
||||
click.secho("[FAIL] Verification failed", fg='red', bold=True)
|
||||
click.secho("✗ Verification failed", fg='red', bold=True)
|
||||
click.echo(f" Error: {e}")
|
||||
sys.exit(1)
|
||||
except StegasooError as e:
|
||||
@@ -695,8 +698,7 @@ def info(image, as_json):
|
||||
"""
|
||||
Show information about an image.
|
||||
|
||||
Displays dimensions, capacity for both LSB and DCT modes,
|
||||
and attempts to detect date from filename.
|
||||
Displays dimensions, capacity for both LSB and DCT modes.
|
||||
"""
|
||||
try:
|
||||
image_data = Path(image).read_bytes()
|
||||
@@ -708,10 +710,6 @@ def info(image, as_json):
|
||||
# Get capacity comparison
|
||||
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:
|
||||
import json
|
||||
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))
|
||||
return
|
||||
|
||||
@@ -753,17 +748,13 @@ def info(image, as_json):
|
||||
click.secho(" Capacity:", bold=True)
|
||||
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 ratio: {comparison['dct']['ratio_vs_lsb']:.1f}% of LSB")
|
||||
|
||||
if comparison['dct']['available']:
|
||||
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()
|
||||
|
||||
except Exception as e:
|
||||
@@ -839,7 +830,7 @@ def compare(image, payload_size, as_json):
|
||||
click.secho(" +--- LSB Mode ---", fg='green')
|
||||
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" | Status: [OK] Available")
|
||||
click.echo(f" | Status: ✓ Available")
|
||||
click.echo(" |")
|
||||
|
||||
# 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" | Ratio: {comparison['dct']['ratio_vs_lsb']:.1f}% of LSB capacity")
|
||||
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" | Colors: Grayscale (default), Color (v3.0.1)")
|
||||
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(" |")
|
||||
|
||||
# Payload check
|
||||
@@ -862,8 +853,8 @@ def compare(image, payload_size, as_json):
|
||||
fits_lsb = payload_size <= comparison['lsb']['capacity_bytes']
|
||||
fits_dct = payload_size <= comparison['dct']['capacity_bytes']
|
||||
|
||||
lsb_icon = "[OK]" if fits_lsb else "[X]"
|
||||
dct_icon = "[OK]" if fits_dct else "[X]"
|
||||
lsb_icon = "✓" if fits_lsb else "✗"
|
||||
dct_icon = "✓" if fits_dct else "✗"
|
||||
lsb_color = 'green' if fits_lsb else 'red'
|
||||
dct_color = 'green' if fits_dct else 'red'
|
||||
|
||||
@@ -884,7 +875,7 @@ def compare(image, payload_size, as_json):
|
||||
elif fits_lsb:
|
||||
click.echo(" LSB mode (payload too large for DCT)")
|
||||
else:
|
||||
click.secho(" [X] Payload too large for both modes!", fg='red')
|
||||
click.secho(" ✗ Payload too large for both modes!", fg='red')
|
||||
else:
|
||||
click.echo(" LSB for larger payloads, DCT for better stealth")
|
||||
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)
|
||||
|
||||
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" Output: {out_path} ({len(clean_data):,} bytes)")
|
||||
|
||||
@@ -951,12 +942,12 @@ def modes():
|
||||
Displays which modes are available and their characteristics.
|
||||
"""
|
||||
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()
|
||||
|
||||
# LSB Mode
|
||||
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(" Capacity: ~375 KB per megapixel")
|
||||
click.echo(" Use case: Larger payloads, color preservation")
|
||||
@@ -966,16 +957,16 @@ def modes():
|
||||
# DCT Mode
|
||||
click.secho(" DCT Mode (Frequency Domain)", fg='blue', bold=True)
|
||||
if has_dct_support():
|
||||
click.echo(" Status: [OK] Available")
|
||||
click.echo(" Status: ✓ Available")
|
||||
else:
|
||||
click.secho(" Status: [X] Requires scipy", fg='yellow')
|
||||
click.secho(" Status: ✗ Requires scipy", fg='yellow')
|
||||
click.echo(" Install: pip install scipy")
|
||||
click.echo(" Capacity: ~75 KB per megapixel (~20% of LSB)")
|
||||
click.echo(" Use case: Better stealth, frequency domain hiding")
|
||||
click.echo(" CLI flag: --mode dct")
|
||||
click.echo()
|
||||
|
||||
# DCT Options (v3.0.1)
|
||||
# DCT Options
|
||||
click.secho(" DCT Options (v3.0.1)", fg='magenta', bold=True)
|
||||
click.echo(" Output format:")
|
||||
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()
|
||||
|
||||
# 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
|
||||
click.secho(" Examples:", dim=True)
|
||||
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.
|
||||
All version numbers, limits, and configuration values should be defined here.
|
||||
|
||||
BREAKING CHANGES in v3.2.0:
|
||||
- Removed date dependency from cryptographic operations
|
||||
- Renamed day_phrase → passphrase throughout codebase
|
||||
- FORMAT_VERSION bumped to 4 to indicate incompatibility
|
||||
- Increased default passphrase length to compensate for removed date entropy
|
||||
"""
|
||||
|
||||
import os
|
||||
@@ -12,14 +18,18 @@ from pathlib import Path
|
||||
# VERSION
|
||||
# ============================================================================
|
||||
|
||||
__version__ = "3.1.0"
|
||||
__version__ = "3.2.0"
|
||||
|
||||
# ============================================================================
|
||||
# FILE FORMAT
|
||||
# ============================================================================
|
||||
|
||||
MAGIC_HEADER = b'\x89ST3'
|
||||
FORMAT_VERSION = 3
|
||||
|
||||
# FORMAT VERSION HISTORY:
|
||||
# Version 1-3: Date-dependent encryption (v3.0.x - v3.1.x)
|
||||
# Version 4: Date-independent encryption (v3.2.0+) - BREAKING CHANGE
|
||||
FORMAT_VERSION = 4
|
||||
|
||||
# Payload type markers
|
||||
PAYLOAD_TEXT = 0x01
|
||||
@@ -46,8 +56,14 @@ PBKDF2_ITERATIONS = 600000
|
||||
# ============================================================================
|
||||
|
||||
MAX_IMAGE_PIXELS = 24_000_000 # ~24 megapixels
|
||||
MIN_IMAGE_PIXELS = 256 * 256 # Minimum viable image size
|
||||
|
||||
MAX_MESSAGE_SIZE = 250_000 # 250 KB (text messages)
|
||||
MAX_MESSAGE_CHARS = 250_000 # Alias for clarity in templates
|
||||
MIN_MESSAGE_LENGTH = 1 # Minimum message length
|
||||
MAX_MESSAGE_LENGTH = MAX_MESSAGE_SIZE # Alias for consistency
|
||||
|
||||
MAX_PAYLOAD_SIZE = MAX_MESSAGE_SIZE # Maximum payload size (alias)
|
||||
MAX_FILENAME_LENGTH = 255 # Max filename length to store
|
||||
|
||||
# File size limits
|
||||
@@ -60,10 +76,17 @@ MIN_PIN_LENGTH = 6
|
||||
MAX_PIN_LENGTH = 9
|
||||
DEFAULT_PIN_LENGTH = 6
|
||||
|
||||
# Phrase configuration
|
||||
MIN_PHRASE_WORDS = 3
|
||||
MAX_PHRASE_WORDS = 12
|
||||
DEFAULT_PHRASE_WORDS = 3
|
||||
# Passphrase configuration (v3.2.0: renamed from PHRASE to PASSPHRASE)
|
||||
# Increased defaults to compensate for removed date entropy (~33 bits)
|
||||
MIN_PASSPHRASE_WORDS = 3
|
||||
MAX_PASSPHRASE_WORDS = 12
|
||||
DEFAULT_PASSPHRASE_WORDS = 4 # Increased from 3 (was DEFAULT_PHRASE_WORDS)
|
||||
RECOMMENDED_PASSPHRASE_WORDS = 4 # Best practice guideline
|
||||
|
||||
# Legacy aliases for backward compatibility during transition
|
||||
MIN_PHRASE_WORDS = MIN_PASSPHRASE_WORDS
|
||||
MAX_PHRASE_WORDS = MAX_PASSPHRASE_WORDS
|
||||
DEFAULT_PHRASE_WORDS = DEFAULT_PASSPHRASE_WORDS
|
||||
|
||||
# RSA configuration
|
||||
MIN_RSA_BITS = 2048
|
||||
@@ -97,8 +120,11 @@ ALLOWED_KEY_EXTENSIONS = {'pem', 'key'}
|
||||
# Lossless image formats (safe for steganography)
|
||||
LOSSLESS_FORMATS = {'PNG', 'BMP', 'TIFF'}
|
||||
|
||||
# Supported image formats for steganography
|
||||
SUPPORTED_IMAGE_FORMATS = LOSSLESS_FORMATS
|
||||
|
||||
# ============================================================================
|
||||
# DAYS
|
||||
# DAYS (kept for organizational/UI purposes, not crypto)
|
||||
# ============================================================================
|
||||
|
||||
DAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday')
|
||||
@@ -184,7 +210,7 @@ def get_wordlist() -> list[str]:
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# DCT STEGANOGRAPHY (v3.0)
|
||||
# DCT STEGANOGRAPHY (v3.0+)
|
||||
# =============================================================================
|
||||
|
||||
# Embedding modes
|
||||
@@ -200,6 +226,10 @@ DCT_STEP_SIZE = 8 # QIM quantization step
|
||||
# Valid embedding modes
|
||||
VALID_EMBED_MODES = {EMBED_MODE_LSB, EMBED_MODE_DCT}
|
||||
|
||||
# Capacity estimation constants
|
||||
LSB_BYTES_PER_PIXEL = 3 / 8 # 3 bits per pixel (RGB, 1 bit per channel) / 8 bits per byte
|
||||
DCT_BYTES_PER_PIXEL = 0.125 # Approximate for DCT mode (varies by implementation)
|
||||
|
||||
|
||||
def detect_stego_mode(encrypted_data: bytes) -> str:
|
||||
"""
|
||||
|
||||
@@ -1,8 +1,15 @@
|
||||
"""
|
||||
Stegasoo Cryptographic Functions
|
||||
Stegasoo Cryptographic Functions (v3.2.0 - Date Independent)
|
||||
|
||||
Key derivation, encryption, and decryption using AES-256-GCM.
|
||||
Supports both text messages and binary file payloads.
|
||||
|
||||
BREAKING CHANGES in v3.2.0:
|
||||
- Removed date dependency from key derivation
|
||||
- Renamed day_phrase → passphrase (no daily rotation needed)
|
||||
- Messages can now be decoded without knowing encoding date
|
||||
- Enables true asynchronous communication
|
||||
- NOT backward compatible with v3.1.0 and earlier
|
||||
"""
|
||||
|
||||
import io
|
||||
@@ -63,8 +70,7 @@ def hash_photo(image_data: bytes) -> bytes:
|
||||
|
||||
def derive_hybrid_key(
|
||||
photo_data: bytes,
|
||||
day_phrase: str,
|
||||
date_str: str,
|
||||
passphrase: str,
|
||||
salt: bytes,
|
||||
pin: str = "",
|
||||
rsa_key_data: Optional[bytes] = None
|
||||
@@ -74,18 +80,19 @@ def derive_hybrid_key(
|
||||
|
||||
Combines:
|
||||
- Photo hash (something you have)
|
||||
- Day phrase (something you know, rotates daily)
|
||||
- Passphrase (something you know)
|
||||
- PIN (something you know, static)
|
||||
- RSA key (something you have)
|
||||
- Date (automatic rotation)
|
||||
- Salt (random per message)
|
||||
|
||||
Uses Argon2id if available, falls back to PBKDF2.
|
||||
|
||||
NOTE: v3.2.0 removed date dependency and daily rotation.
|
||||
Use a strong static passphrase instead (recommend 4+ words).
|
||||
|
||||
Args:
|
||||
photo_data: Reference photo bytes
|
||||
day_phrase: The day's phrase
|
||||
date_str: Date string (YYYY-MM-DD)
|
||||
passphrase: Shared passphrase (recommend 4+ words)
|
||||
salt: Random salt for this message
|
||||
pin: Optional static PIN
|
||||
rsa_key_data: Optional RSA key bytes
|
||||
@@ -101,9 +108,8 @@ def derive_hybrid_key(
|
||||
|
||||
key_material = (
|
||||
photo_hash +
|
||||
day_phrase.lower().encode() +
|
||||
passphrase.lower().encode() +
|
||||
pin.encode() +
|
||||
date_str.encode() +
|
||||
salt
|
||||
)
|
||||
|
||||
@@ -139,8 +145,7 @@ def derive_hybrid_key(
|
||||
|
||||
def derive_pixel_key(
|
||||
photo_data: bytes,
|
||||
day_phrase: str,
|
||||
date_str: str,
|
||||
passphrase: str,
|
||||
pin: str = "",
|
||||
rsa_key_data: Optional[bytes] = None
|
||||
) -> bytes:
|
||||
@@ -150,10 +155,11 @@ def derive_pixel_key(
|
||||
This key determines which pixels are used for embedding,
|
||||
making the message location unpredictable without the correct inputs.
|
||||
|
||||
NOTE: v3.2.0 removed date dependency.
|
||||
|
||||
Args:
|
||||
photo_data: Reference photo bytes
|
||||
day_phrase: The day's phrase
|
||||
date_str: Date string (YYYY-MM-DD)
|
||||
passphrase: Shared passphrase
|
||||
pin: Optional static PIN
|
||||
rsa_key_data: Optional RSA key bytes
|
||||
|
||||
@@ -164,9 +170,8 @@ def derive_pixel_key(
|
||||
|
||||
material = (
|
||||
photo_hash +
|
||||
day_phrase.lower().encode() +
|
||||
pin.encode() +
|
||||
date_str.encode()
|
||||
passphrase.lower().encode() +
|
||||
pin.encode()
|
||||
)
|
||||
|
||||
if rsa_key_data:
|
||||
@@ -282,19 +287,16 @@ def _unpack_payload(data: bytes) -> DecodeResult:
|
||||
def encrypt_message(
|
||||
message: Union[str, bytes, FilePayload],
|
||||
photo_data: bytes,
|
||||
day_phrase: str,
|
||||
date_str: str,
|
||||
passphrase: str,
|
||||
pin: str = "",
|
||||
rsa_key_data: Optional[bytes] = None
|
||||
) -> bytes:
|
||||
"""
|
||||
Encrypt message or file using AES-256-GCM with hybrid key derivation.
|
||||
|
||||
Message format:
|
||||
Message format (v3.2.0 - no date):
|
||||
- Magic header (4 bytes)
|
||||
- Version (1 byte)
|
||||
- Date length (1 byte)
|
||||
- Date string (variable)
|
||||
- Version (1 byte) = 4
|
||||
- Salt (32 bytes)
|
||||
- IV (12 bytes)
|
||||
- Auth tag (16 bytes)
|
||||
@@ -303,8 +305,7 @@ def encrypt_message(
|
||||
Args:
|
||||
message: Message string, raw bytes, or FilePayload to encrypt
|
||||
photo_data: Reference photo bytes
|
||||
day_phrase: The day's phrase
|
||||
date_str: Date string (YYYY-MM-DD)
|
||||
passphrase: Shared passphrase (recommend 4+ words for good entropy)
|
||||
pin: Optional static PIN
|
||||
rsa_key_data: Optional RSA key bytes
|
||||
|
||||
@@ -316,7 +317,7 @@ def encrypt_message(
|
||||
"""
|
||||
try:
|
||||
salt = secrets.token_bytes(SALT_SIZE)
|
||||
key = derive_hybrid_key(photo_data, day_phrase, date_str, salt, pin, rsa_key_data)
|
||||
key = derive_hybrid_key(photo_data, passphrase, salt, pin, rsa_key_data)
|
||||
iv = secrets.token_bytes(IV_SIZE)
|
||||
|
||||
# Pack payload with type marker
|
||||
@@ -335,13 +336,10 @@ def encrypt_message(
|
||||
encryptor.authenticate_additional_data(MAGIC_HEADER + bytes([FORMAT_VERSION]))
|
||||
ciphertext = encryptor.update(padded_message) + encryptor.finalize()
|
||||
|
||||
date_bytes = date_str.encode()
|
||||
|
||||
# v3.2.0: Simplified header without date
|
||||
return (
|
||||
MAGIC_HEADER +
|
||||
bytes([FORMAT_VERSION]) +
|
||||
bytes([len(date_bytes)]) +
|
||||
date_bytes +
|
||||
salt +
|
||||
iv +
|
||||
encryptor.tag +
|
||||
@@ -356,13 +354,16 @@ def parse_header(encrypted_data: bytes) -> Optional[dict]:
|
||||
"""
|
||||
Parse the header from encrypted data.
|
||||
|
||||
v3.2.0: No date field in header.
|
||||
|
||||
Args:
|
||||
encrypted_data: Raw encrypted bytes
|
||||
|
||||
Returns:
|
||||
Dict with date, salt, iv, tag, ciphertext or None if invalid
|
||||
Dict with salt, iv, tag, ciphertext or None if invalid
|
||||
"""
|
||||
if len(encrypted_data) < 10 or encrypted_data[:4] != MAGIC_HEADER:
|
||||
# Min size: Magic(4) + Version(1) + Salt(32) + IV(12) + Tag(16) = 65 bytes
|
||||
if len(encrypted_data) < 65 or encrypted_data[:4] != MAGIC_HEADER:
|
||||
return None
|
||||
|
||||
try:
|
||||
@@ -370,10 +371,7 @@ def parse_header(encrypted_data: bytes) -> Optional[dict]:
|
||||
if version != FORMAT_VERSION:
|
||||
return None
|
||||
|
||||
date_len = encrypted_data[5]
|
||||
date_str = encrypted_data[6:6 + date_len].decode()
|
||||
|
||||
offset = 6 + date_len
|
||||
offset = 5
|
||||
salt = encrypted_data[offset:offset + SALT_SIZE]
|
||||
offset += SALT_SIZE
|
||||
iv = encrypted_data[offset:offset + IV_SIZE]
|
||||
@@ -383,7 +381,6 @@ def parse_header(encrypted_data: bytes) -> Optional[dict]:
|
||||
ciphertext = encrypted_data[offset:]
|
||||
|
||||
return {
|
||||
'date': date_str,
|
||||
'salt': salt,
|
||||
'iv': iv,
|
||||
'tag': tag,
|
||||
@@ -396,17 +393,17 @@ def parse_header(encrypted_data: bytes) -> Optional[dict]:
|
||||
def decrypt_message(
|
||||
encrypted_data: bytes,
|
||||
photo_data: bytes,
|
||||
day_phrase: str,
|
||||
passphrase: str,
|
||||
pin: str = "",
|
||||
rsa_key_data: Optional[bytes] = None
|
||||
) -> DecodeResult:
|
||||
"""
|
||||
Decrypt message using the embedded date from the header.
|
||||
Decrypt message (v3.2.0 - no date needed).
|
||||
|
||||
Args:
|
||||
encrypted_data: Encrypted message bytes
|
||||
photo_data: Reference photo bytes
|
||||
day_phrase: The day's phrase (must match encoding day)
|
||||
passphrase: Shared passphrase
|
||||
pin: Optional static PIN
|
||||
rsa_key_data: Optional RSA key bytes
|
||||
|
||||
@@ -423,7 +420,7 @@ def decrypt_message(
|
||||
|
||||
try:
|
||||
key = derive_hybrid_key(
|
||||
photo_data, day_phrase, header['date'], header['salt'], pin, rsa_key_data
|
||||
photo_data, passphrase, header['salt'], pin, rsa_key_data
|
||||
)
|
||||
|
||||
cipher = Cipher(
|
||||
@@ -439,20 +436,21 @@ def decrypt_message(
|
||||
|
||||
payload_data = padded_plaintext[:original_length]
|
||||
result = _unpack_payload(payload_data)
|
||||
result.date_encoded = header['date']
|
||||
|
||||
# Note: No date_encoded field in v3.2.0
|
||||
|
||||
return result
|
||||
|
||||
except Exception as e:
|
||||
raise DecryptionError(
|
||||
"Decryption failed. Check your phrase, PIN, RSA key, and reference photo."
|
||||
"Decryption failed. Check your passphrase, PIN, RSA key, and reference photo."
|
||||
) from e
|
||||
|
||||
|
||||
def decrypt_message_text(
|
||||
encrypted_data: bytes,
|
||||
photo_data: bytes,
|
||||
day_phrase: str,
|
||||
passphrase: str,
|
||||
pin: str = "",
|
||||
rsa_key_data: Optional[bytes] = None
|
||||
) -> str:
|
||||
@@ -464,7 +462,7 @@ def decrypt_message_text(
|
||||
Args:
|
||||
encrypted_data: Encrypted message bytes
|
||||
photo_data: Reference photo bytes
|
||||
day_phrase: The day's phrase
|
||||
passphrase: Shared passphrase
|
||||
pin: Optional static PIN
|
||||
rsa_key_data: Optional RSA key bytes
|
||||
|
||||
@@ -474,7 +472,7 @@ def decrypt_message_text(
|
||||
Raises:
|
||||
DecryptionError: If decryption fails or content is a file
|
||||
"""
|
||||
result = decrypt_message(encrypted_data, photo_data, day_phrase, pin, rsa_key_data)
|
||||
result = decrypt_message(encrypted_data, photo_data, passphrase, pin, rsa_key_data)
|
||||
|
||||
if result.is_file:
|
||||
if result.file_data:
|
||||
@@ -490,22 +488,6 @@ def decrypt_message_text(
|
||||
return result.message or ""
|
||||
|
||||
|
||||
def get_date_from_encrypted(encrypted_data: bytes) -> Optional[str]:
|
||||
"""
|
||||
Extract the date string from encrypted data without decrypting.
|
||||
|
||||
Useful for determining which day's phrase to use.
|
||||
|
||||
Args:
|
||||
encrypted_data: Encrypted message bytes
|
||||
|
||||
Returns:
|
||||
Date string (YYYY-MM-DD) or None if invalid
|
||||
"""
|
||||
header = parse_header(encrypted_data)
|
||||
return header['date'] if header else None
|
||||
|
||||
|
||||
def has_argon2() -> bool:
|
||||
"""Check if Argon2 is available."""
|
||||
return HAS_ARGON2
|
||||
|
||||
208
src/stegasoo/decode.py
Normal file
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.
|
||||
|
||||
Changes in v3.2.0:
|
||||
- Renamed day_phrase → passphrase
|
||||
- Credentials now uses single passphrase instead of day mapping
|
||||
- Removed date_str from EncodeInput (date no longer used in crypto)
|
||||
- Made date_used optional in EncodeResult (cosmetic only)
|
||||
- Added ImageInfo, CapacityComparison, GenerateResult
|
||||
"""
|
||||
|
||||
from dataclasses import dataclass, field
|
||||
from datetime import date
|
||||
from typing import Optional, Union
|
||||
from typing import Optional, Union, List
|
||||
|
||||
|
||||
@dataclass
|
||||
class Credentials:
|
||||
"""Generated credentials for encoding/decoding."""
|
||||
phrases: dict[str, str] # Day -> phrase mapping
|
||||
"""
|
||||
Generated credentials for encoding/decoding.
|
||||
|
||||
v3.2.0: Simplified to use single passphrase instead of daily rotation.
|
||||
"""
|
||||
passphrase: str # Single passphrase (no daily rotation)
|
||||
pin: Optional[str] = None
|
||||
rsa_key_pem: Optional[str] = None
|
||||
rsa_bits: Optional[int] = None
|
||||
words_per_phrase: int = 3
|
||||
words_per_passphrase: int = 4 # Increased from 3 in v3.1.0
|
||||
|
||||
# Optional: backup passphrases for multi-factor or rotation
|
||||
backup_passphrases: Optional[list[str]] = None
|
||||
|
||||
@property
|
||||
def phrase_entropy(self) -> int:
|
||||
"""Entropy in bits from phrases (~11 bits per BIP-39 word)."""
|
||||
return self.words_per_phrase * 11
|
||||
def passphrase_entropy(self) -> int:
|
||||
"""Entropy in bits from passphrase (~11 bits per BIP-39 word)."""
|
||||
return self.words_per_passphrase * 11
|
||||
|
||||
@property
|
||||
def pin_entropy(self) -> int:
|
||||
@@ -40,7 +54,13 @@ class Credentials:
|
||||
@property
|
||||
def total_entropy(self) -> int:
|
||||
"""Total entropy in bits (excluding reference photo)."""
|
||||
return self.phrase_entropy + self.pin_entropy + self.rsa_entropy
|
||||
return self.passphrase_entropy + self.pin_entropy + self.rsa_entropy
|
||||
|
||||
# Legacy property for compatibility
|
||||
@property
|
||||
def phrase_entropy(self) -> int:
|
||||
"""Alias for passphrase_entropy (backward compatibility)."""
|
||||
return self.passphrase_entropy
|
||||
|
||||
|
||||
@dataclass
|
||||
@@ -70,30 +90,33 @@ class FilePayload:
|
||||
|
||||
@dataclass
|
||||
class EncodeInput:
|
||||
"""Input parameters for encoding a message."""
|
||||
"""
|
||||
Input parameters for encoding a message.
|
||||
|
||||
v3.2.0: Removed date_str (date no longer used in crypto).
|
||||
"""
|
||||
message: Union[str, bytes, FilePayload] # Text, raw bytes, or file
|
||||
reference_photo: bytes
|
||||
carrier_image: bytes
|
||||
day_phrase: str
|
||||
passphrase: str # Renamed from day_phrase
|
||||
pin: str = ""
|
||||
rsa_key_data: Optional[bytes] = None
|
||||
rsa_password: Optional[str] = None
|
||||
date_str: Optional[str] = None # YYYY-MM-DD, defaults to today
|
||||
|
||||
def __post_init__(self):
|
||||
if self.date_str is None:
|
||||
self.date_str = date.today().isoformat()
|
||||
|
||||
|
||||
@dataclass
|
||||
class EncodeResult:
|
||||
"""Result of encoding operation."""
|
||||
"""
|
||||
Result of encoding operation.
|
||||
|
||||
v3.2.0: date_used is now optional/cosmetic (not used in crypto).
|
||||
"""
|
||||
stego_image: bytes
|
||||
filename: str
|
||||
pixels_modified: int
|
||||
total_pixels: int
|
||||
capacity_used: float # 0.0 - 1.0
|
||||
date_used: str
|
||||
date_used: Optional[str] = None # Cosmetic only (for filename organization)
|
||||
|
||||
@property
|
||||
def capacity_percent(self) -> float:
|
||||
@@ -103,10 +126,14 @@ class EncodeResult:
|
||||
|
||||
@dataclass
|
||||
class DecodeInput:
|
||||
"""Input parameters for decoding a message."""
|
||||
"""
|
||||
Input parameters for decoding a message.
|
||||
|
||||
v3.2.0: Renamed day_phrase → passphrase, no date needed.
|
||||
"""
|
||||
stego_image: bytes
|
||||
reference_photo: bytes
|
||||
day_phrase: str
|
||||
passphrase: str # Renamed from day_phrase
|
||||
pin: str = ""
|
||||
rsa_key_data: Optional[bytes] = None
|
||||
rsa_password: Optional[str] = None
|
||||
@@ -114,13 +141,17 @@ class DecodeInput:
|
||||
|
||||
@dataclass
|
||||
class DecodeResult:
|
||||
"""Result of decoding operation."""
|
||||
"""
|
||||
Result of decoding operation.
|
||||
|
||||
v3.2.0: date_encoded is always None (date removed from crypto).
|
||||
"""
|
||||
payload_type: str # 'text' or 'file'
|
||||
message: Optional[str] = None # For text payloads
|
||||
file_data: Optional[bytes] = None # For file payloads
|
||||
filename: Optional[str] = None # Original filename for file payloads
|
||||
mime_type: Optional[str] = None # MIME type hint
|
||||
date_encoded: Optional[str] = None
|
||||
date_encoded: Optional[str] = None # Always None in v3.2.0 (kept for compatibility)
|
||||
|
||||
@property
|
||||
def is_file(self) -> bool:
|
||||
@@ -165,13 +196,77 @@ class ValidationResult:
|
||||
is_valid: bool
|
||||
error_message: str = ""
|
||||
details: dict = field(default_factory=dict)
|
||||
warning: Optional[str] = None # v3.2.0: Added for passphrase length warnings
|
||||
|
||||
@classmethod
|
||||
def ok(cls, **details) -> 'ValidationResult':
|
||||
def ok(cls, warning: Optional[str] = None, **details) -> 'ValidationResult':
|
||||
"""Create a successful validation result."""
|
||||
return cls(is_valid=True, details=details)
|
||||
result = cls(is_valid=True, details=details)
|
||||
if warning:
|
||||
result.warning = warning
|
||||
return result
|
||||
|
||||
@classmethod
|
||||
def error(cls, message: str, **details) -> 'ValidationResult':
|
||||
"""Create a failed validation result."""
|
||||
return cls(is_valid=False, error_message=message, details=details)
|
||||
|
||||
|
||||
# =============================================================================
|
||||
# NEW MODELS FOR V3.2.0 PUBLIC API
|
||||
# =============================================================================
|
||||
|
||||
@dataclass
|
||||
class ImageInfo:
|
||||
"""Information about an image for steganography."""
|
||||
width: int
|
||||
height: int
|
||||
pixels: int
|
||||
format: str
|
||||
mode: str
|
||||
file_size: int
|
||||
lsb_capacity_bytes: int
|
||||
lsb_capacity_kb: float
|
||||
dct_capacity_bytes: Optional[int] = None
|
||||
dct_capacity_kb: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class CapacityComparison:
|
||||
"""Comparison of embedding capacity between modes."""
|
||||
image_width: int
|
||||
image_height: int
|
||||
lsb_available: bool
|
||||
lsb_bytes: int
|
||||
lsb_kb: float
|
||||
lsb_output_format: str
|
||||
dct_available: bool
|
||||
dct_bytes: Optional[int] = None
|
||||
dct_kb: Optional[float] = None
|
||||
dct_output_formats: Optional[List[str]] = None
|
||||
dct_ratio_vs_lsb: Optional[float] = None
|
||||
|
||||
|
||||
@dataclass
|
||||
class GenerateResult:
|
||||
"""Result of credential generation."""
|
||||
passphrase: str
|
||||
pin: Optional[str] = None
|
||||
rsa_key_pem: Optional[str] = None
|
||||
passphrase_words: int = 4
|
||||
passphrase_entropy: int = 0
|
||||
pin_entropy: int = 0
|
||||
rsa_entropy: int = 0
|
||||
total_entropy: int = 0
|
||||
|
||||
def __str__(self) -> str:
|
||||
lines = [
|
||||
"Generated Credentials:",
|
||||
f" Passphrase: {self.passphrase}",
|
||||
]
|
||||
if self.pin:
|
||||
lines.append(f" PIN: {self.pin}")
|
||||
if self.rsa_key_pem:
|
||||
lines.append(f" RSA Key: {len(self.rsa_key_pem)} bytes PEM")
|
||||
lines.append(f" Total Entropy: {self.total_entropy} bits")
|
||||
return "\n".join(lines)
|
||||
|
||||
@@ -1,7 +1,12 @@
|
||||
"""
|
||||
Stegasoo Input Validation
|
||||
Stegasoo Input Validation (v3.2.0)
|
||||
|
||||
Validators for all user inputs with clear error messages.
|
||||
|
||||
Changes in v3.2.0:
|
||||
- Renamed validate_phrase() → validate_passphrase()
|
||||
- Added word count validation with warnings for passphrases
|
||||
- Added validators for embed modes and DCT parameters
|
||||
"""
|
||||
|
||||
import io
|
||||
@@ -14,6 +19,8 @@ from .constants import (
|
||||
MAX_MESSAGE_SIZE, MAX_FILE_PAYLOAD_SIZE, MAX_IMAGE_PIXELS, MAX_FILE_SIZE,
|
||||
MIN_RSA_BITS, MIN_KEY_PASSWORD_LENGTH,
|
||||
ALLOWED_IMAGE_EXTENSIONS, ALLOWED_KEY_EXTENSIONS,
|
||||
MIN_PASSPHRASE_WORDS, RECOMMENDED_PASSPHRASE_WORDS,
|
||||
EMBED_MODE_LSB, EMBED_MODE_DCT, EMBED_MODE_AUTO,
|
||||
)
|
||||
from .models import ValidationResult, FilePayload
|
||||
from .exceptions import (
|
||||
@@ -325,55 +332,110 @@ def validate_key_password(password: str) -> ValidationResult:
|
||||
return ValidationResult.ok(length=len(password))
|
||||
|
||||
|
||||
def validate_phrase(phrase: str) -> ValidationResult:
|
||||
def validate_passphrase(passphrase: str) -> ValidationResult:
|
||||
"""
|
||||
Validate day phrase.
|
||||
Validate passphrase.
|
||||
|
||||
v3.2.0: Recommend 4+ words for good entropy (since date is no longer used).
|
||||
|
||||
Args:
|
||||
phrase: Phrase string
|
||||
passphrase: Passphrase string
|
||||
|
||||
Returns:
|
||||
ValidationResult with word_count
|
||||
ValidationResult with word_count and optional warning
|
||||
"""
|
||||
if not phrase or not phrase.strip():
|
||||
return ValidationResult.error("Day phrase is required")
|
||||
if not passphrase or not passphrase.strip():
|
||||
return ValidationResult.error("Passphrase is required")
|
||||
|
||||
words = phrase.strip().split()
|
||||
words = passphrase.strip().split()
|
||||
|
||||
if len(words) < MIN_PASSPHRASE_WORDS:
|
||||
return ValidationResult.error(
|
||||
f"Passphrase should have at least {MIN_PASSPHRASE_WORDS} words"
|
||||
)
|
||||
|
||||
# Provide warning if below recommended length
|
||||
if len(words) < RECOMMENDED_PASSPHRASE_WORDS:
|
||||
return ValidationResult.ok(
|
||||
word_count=len(words),
|
||||
warning=f"Recommend {RECOMMENDED_PASSPHRASE_WORDS}+ words for better security"
|
||||
)
|
||||
|
||||
return ValidationResult.ok(word_count=len(words))
|
||||
|
||||
|
||||
def validate_date_string(date_str: str) -> ValidationResult:
|
||||
# =============================================================================
|
||||
# NEW VALIDATORS FOR V3.2.0
|
||||
# =============================================================================
|
||||
|
||||
def validate_reference_photo(photo_data: bytes) -> ValidationResult:
|
||||
"""Validate reference photo. Alias for validate_image."""
|
||||
return validate_image(photo_data, "Reference photo")
|
||||
|
||||
|
||||
def validate_carrier(carrier_data: bytes) -> ValidationResult:
|
||||
"""Validate carrier image. Alias for validate_image."""
|
||||
return validate_image(carrier_data, "Carrier image")
|
||||
|
||||
|
||||
def validate_embed_mode(mode: str) -> ValidationResult:
|
||||
"""
|
||||
Validate date string format (YYYY-MM-DD).
|
||||
Validate embedding mode.
|
||||
|
||||
Args:
|
||||
date_str: Date string
|
||||
mode: Embedding mode string
|
||||
|
||||
Returns:
|
||||
ValidationResult
|
||||
"""
|
||||
if not date_str:
|
||||
return ValidationResult.error("Date is required")
|
||||
valid_modes = {EMBED_MODE_LSB, EMBED_MODE_DCT, EMBED_MODE_AUTO}
|
||||
|
||||
if len(date_str) != 10:
|
||||
return ValidationResult.error("Date must be in YYYY-MM-DD format")
|
||||
if mode not in valid_modes:
|
||||
return ValidationResult.error(
|
||||
f"Invalid embed_mode: '{mode}'. Valid options: {', '.join(sorted(valid_modes))}"
|
||||
)
|
||||
|
||||
if date_str[4] != '-' or date_str[7] != '-':
|
||||
return ValidationResult.error("Date must be in YYYY-MM-DD format")
|
||||
return ValidationResult.ok(mode=mode)
|
||||
|
||||
|
||||
def validate_dct_output_format(format_str: str) -> ValidationResult:
|
||||
"""
|
||||
Validate DCT output format.
|
||||
|
||||
try:
|
||||
year = int(date_str[0:4])
|
||||
month = int(date_str[5:7])
|
||||
day = int(date_str[8:10])
|
||||
Args:
|
||||
format_str: Output format ('png' or 'jpeg')
|
||||
|
||||
if not (1 <= month <= 12 and 1 <= day <= 31 and 2000 <= year <= 2100):
|
||||
return ValidationResult.error("Invalid date values")
|
||||
Returns:
|
||||
ValidationResult
|
||||
"""
|
||||
valid_formats = {'png', 'jpeg'}
|
||||
|
||||
if format_str.lower() not in valid_formats:
|
||||
return ValidationResult.error(
|
||||
f"Invalid DCT output format: '{format_str}'. Valid options: {', '.join(sorted(valid_formats))}"
|
||||
)
|
||||
|
||||
return ValidationResult.ok(format=format_str.lower())
|
||||
|
||||
|
||||
def validate_dct_color_mode(mode: str) -> ValidationResult:
|
||||
"""
|
||||
Validate DCT color mode.
|
||||
|
||||
Args:
|
||||
mode: Color mode ('grayscale' or 'color')
|
||||
|
||||
return ValidationResult.ok(year=year, month=month, day=day)
|
||||
|
||||
except ValueError:
|
||||
return ValidationResult.error("Date must contain valid numbers")
|
||||
Returns:
|
||||
ValidationResult
|
||||
"""
|
||||
valid_modes = {'grayscale', 'color'}
|
||||
|
||||
if mode.lower() not in valid_modes:
|
||||
return ValidationResult.error(
|
||||
f"Invalid DCT color mode: '{mode}'. Valid options: {', '.join(sorted(valid_modes))}"
|
||||
)
|
||||
|
||||
return ValidationResult.ok(mode=mode.lower())
|
||||
|
||||
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user