diff --git a/frontends/api/API_UPDATE_SUMMARY_V3.2.0.md b/frontends/api/API_UPDATE_SUMMARY_V3.2.0.md new file mode 100644 index 0000000..d66a130 --- /dev/null +++ b/frontends/api/API_UPDATE_SUMMARY_V3.2.0.md @@ -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! diff --git a/frontends/api/main.py b/frontends/api/main.py index fc8858c..d669cbd 100644 --- a/frontends/api/main.py +++ b/frontends/api/main.py @@ -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)", ), } diff --git a/frontends/cli/main.py b/frontends/cli/main.py index 1493455..f0b3720 100644 --- a/frontends/cli/main.py +++ b/frontends/cli/main.py @@ -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)") diff --git a/src/stegasoo/COMPLETE_CHANGE_SUMMARY_V3.2.0.md b/src/stegasoo/COMPLETE_CHANGE_SUMMARY_V3.2.0.md new file mode 100644 index 0000000..28e29a3 --- /dev/null +++ b/src/stegasoo/COMPLETE_CHANGE_SUMMARY_V3.2.0.md @@ -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 diff --git a/src/stegasoo/__init__.py b/src/stegasoo/__init__.py index 8b0f101..24dd291 100644 --- a/src/stegasoo/__init__.py +++ b/src/stegasoo/__init__.py @@ -1,130 +1,89 @@ """ -Stegasoo - Secure Steganography Library (v3.0.1) - -A Python library for hiding encrypted messages and files in images using -hybrid photo + passphrase + PIN authentication. - -Basic Usage - Text Message: - from stegasoo import encode, decode, generate_credentials - - # Generate credentials - creds = generate_credentials(use_pin=True, use_rsa=False) - print(creds.phrases['Monday']) - print(creds.pin) - - # Encode a message - with open('secret.jpg', 'rb') as f: - ref_photo = f.read() - with open('meme.png', 'rb') as f: - carrier = f.read() - - result = encode( - message="Meet at midnight", - reference_photo=ref_photo, - carrier_image=carrier, - day_phrase="apple forest thunder", - pin="123456" - ) - - with open('stego.png', 'wb') as f: - f.write(result.stego_image) - - # Decode a message - decoded = decode( - stego_image=result.stego_image, - reference_photo=ref_photo, - day_phrase="apple forest thunder", - pin="123456" - ) - print(decoded.message) # "Meet at midnight" - -File Embedding: - from stegasoo import encode_file, decode, FilePayload - - # Encode a file - result = encode_file( - filepath="secret_document.pdf", - reference_photo=ref_photo, - carrier_image=carrier, - day_phrase="apple forest thunder", - pin="123456" - ) - - # Decode - automatically detects file vs text - decoded = decode(...) - if decoded.is_file: - with open(decoded.filename, 'wb') as f: - f.write(decoded.file_data) - else: - print(decoded.message) - -Capacity Pre-check: - from stegasoo import will_fit - - # Check if payload will fit before encoding - result = will_fit("My secret message", carrier_image) - if result['fits']: - print(f"Will use {result['usage_percent']:.1f}% capacity") - else: - print(f"Need {-result['headroom']} more bytes") - -NEW in v3.0 - DCT Embedding Mode: - from stegasoo import encode, has_dct_support, compare_modes - - # Check if DCT mode is available (requires scipy) - if has_dct_support(): - # DCT mode: smaller capacity, grayscale output, frequency domain - result = encode( - message="Secret", - reference_photo=ref_photo, - carrier_image=carrier, - day_phrase="apple forest thunder", - pin="123456", - embed_mode='dct', # NEW parameter - ) - - # Compare mode capacities - info = compare_modes(carrier_image) - print(f"LSB capacity: {info['lsb']['capacity_kb']:.1f} KB") - print(f"DCT capacity: {info['dct']['capacity_kb']:.1f} KB") - -NEW in v3.0.1 - DCT Output Format: - # DCT mode can output PNG (lossless) or JPEG (smaller, natural) - result = encode( - message="Secret", - ..., - embed_mode='dct', - dct_output_format='jpeg', # 'png' (default) or 'jpeg' - ) - -Debugging: - from stegasoo.debug import debug - debug.enable(True) # Enable debug output - debug.enable_performance(True) # Enable timing +Stegasoo - Secure Steganography with Multi-Factor Authentication (v3.2.0) """ -from .constants import ( - __version__, - DAY_NAMES, - MAX_MESSAGE_SIZE, - MAX_FILE_PAYLOAD_SIZE, - # NEW in v3.0 - Embedding modes - EMBED_MODE_LSB, - EMBED_MODE_DCT, - EMBED_MODE_AUTO, - detect_stego_mode, +__version__ = "3.2.0" + +# Core functionality +from .encode import encode +from .decode import decode, decode_file + +# Credential generation +from .generate import ( + generate_pin, + generate_passphrase, + generate_rsa_key, + generate_credentials, + export_rsa_key_pem, + load_rsa_key, ) + +# Image utilities +from .image_utils import ( + get_image_info, + compare_capacity, +) + +# Utilities +from .utils import generate_filename + +# Crypto functions +from .crypto import has_argon2 + +# Steganography functions +from .steganography import ( + has_dct_support, + compare_modes, + will_fit_by_mode, +) + +# QR Code utilities - optional, may not be available +try: + from .qr_utils import ( + generate_qr_code, + extract_key_from_qr, + ) + HAS_QR_UTILS = True +except ImportError: + HAS_QR_UTILS = False + generate_qr_code = None + extract_key_from_qr = None + +# Validation +from .validation import ( + validate_passphrase, + validate_pin, + validate_rsa_key, + validate_message, + validate_file_payload, + validate_image, + validate_security_factors, +) + +# Validation aliases for public API +validate_reference_photo = validate_image +validate_carrier = validate_image + +# Additional validators +from .validation import ( + validate_embed_mode, + validate_dct_output_format, + validate_dct_color_mode, +) + +# Models from .models import ( - Credentials, - EncodeInput, + ImageInfo, + CapacityComparison, + GenerateResult, EncodeResult, - DecodeInput, DecodeResult, - EmbedStats, - KeyInfo, - ValidationResult, FilePayload, + Credentials, + ValidationResult, ) + +# Exceptions from .exceptions import ( StegasooError, ValidationError, @@ -145,780 +104,132 @@ from .exceptions import ( EmbeddingError, InvalidHeaderError, ) -from .keygen import ( - generate_credentials, - generate_pin, - generate_phrase, - generate_day_phrases, - generate_rsa_key, - export_rsa_key_pem, - load_rsa_key, - get_key_info, -) -from .validation import ( - validate_pin, - validate_message, - validate_payload, - validate_file_payload, - validate_image, - validate_rsa_key, - validate_security_factors, - validate_phrase, - validate_date_string, - require_valid_pin, - require_valid_message, - require_valid_payload, - require_valid_image, - require_valid_rsa_key, - require_security_factors, -) -from .crypto import ( - encrypt_message, - decrypt_message, - decrypt_message_text, - derive_hybrid_key, - derive_pixel_key, - hash_photo, - parse_header, - get_date_from_encrypted, - has_argon2, -) -from .steganography import ( - embed_in_image, - extract_from_image, - calculate_capacity, - get_image_dimensions, - get_image_format, - is_lossless_format, + +# Constants +from .constants import ( + FORMAT_VERSION, + MIN_PASSPHRASE_WORDS, + RECOMMENDED_PASSPHRASE_WORDS, + DEFAULT_PASSPHRASE_WORDS, + MAX_PASSPHRASE_WORDS, + MIN_PIN_LENGTH, + MAX_PIN_LENGTH, + MAX_MESSAGE_SIZE, + MIN_IMAGE_PIXELS, + MAX_IMAGE_PIXELS, LOSSLESS_FORMATS, - will_fit, - # NEW in v3.0 - has_dct_support, - calculate_capacity_by_mode, - will_fit_by_mode, - get_available_modes, - compare_modes, -) -from .utils import ( - generate_filename, - parse_date_from_filename, - get_day_from_date, - get_today_date, - get_today_day, - secure_delete, - SecureDeleter, - format_file_size, - strip_image_metadata, -) -from .debug import debug # Import debug utilities - -# ============================================================================= -# Compression -# ============================================================================= -from .compression import ( - compress, - decompress, - CompressionAlgorithm, - CompressionError, - get_compression_ratio, - estimate_compressed_size, - get_available_algorithms, + EMBED_MODE_LSB, + EMBED_MODE_DCT, + EMBED_MODE_AUTO, ) -# ============================================================================= -# Batch Processing -# ============================================================================= -from .batch import ( - BatchProcessor, - BatchResult, - BatchItem, - BatchStatus, - batch_capacity_check, - BatchCredentials, -) - -# ============================================================================= -# NEW in v3.0 - DCT Steganography (optional, requires scipy) -# ============================================================================= -try: - from .dct_steganography import ( - embed_in_dct, - extract_from_dct, - calculate_dct_capacity, - will_fit_dct, - estimate_capacity_comparison, - DCTEmbedStats, - DCTCapacityInfo, - ) - HAS_DCT = True -except ImportError: - HAS_DCT = False - # Provide stub functions that raise helpful errors - def embed_in_dct(*args, **kwargs): - raise ImportError("DCT mode requires scipy. Install: pip install scipy") - def extract_from_dct(*args, **kwargs): - raise ImportError("DCT mode requires scipy. Install: pip install scipy") - def calculate_dct_capacity(*args, **kwargs): - raise ImportError("DCT mode requires scipy. Install: pip install scipy") - def will_fit_dct(*args, **kwargs): - raise ImportError("DCT mode requires scipy. Install: pip install scipy") - def estimate_capacity_comparison(*args, **kwargs): - raise ImportError("DCT mode requires scipy. Install: pip install scipy") - - # Stub classes - class DCTEmbedStats: - pass - class DCTCapacityInfo: - pass - -# QR Code utilities (optional, depends on qrcode and pyzbar) -try: - from .qr_utils import ( - generate_qr_code, - read_qr_code, - read_qr_code_from_file, - extract_key_from_qr, - extract_key_from_qr_file, - compress_data, - decompress_data, - auto_decompress, - normalize_pem, - is_compressed, - can_fit_in_qr, - needs_compression, - has_qr_read, - has_qr_write, - has_qr_support, - ) - HAS_QR_UTILS = True -except ImportError: - HAS_QR_UTILS = False - -from datetime import date -from pathlib import Path -from typing import Optional, Union, Dict, Any - - -# ============================================================================= -# ENCODE FUNCTION (v3.0.1 - with dct_output_format) -# ============================================================================= - -def encode( - message, # Union[str, bytes, FilePayload] - reference_photo: bytes, - carrier_image: bytes, - day_phrase: str, - pin: str = "", - rsa_key_data = None, # Optional[bytes] - rsa_password = None, # Optional[str] - date_str = None, # Optional[str] - output_format = None, # Optional[str] - embed_mode: str = EMBED_MODE_LSB, - dct_output_format: str = "png", # NEW in v3.0.1: 'png' or 'jpeg' - dct_color_mode: str = "grayscale", # NEW in v3.0.1: 'grayscale' or 'color' -) -> EncodeResult: - """ - Encode a secret message or file into an image. - - High-level convenience function that handles validation, - encryption, and embedding in one call. - - Args: - message: Secret message (str), raw bytes, or FilePayload to hide - reference_photo: Shared reference photo bytes - carrier_image: Image to hide message in - day_phrase: Today's passphrase - pin: Static PIN (optional if using RSA key) - rsa_key_data: RSA private key PEM bytes (optional if using PIN) - rsa_password: Password for RSA key if encrypted - date_str: Date string YYYY-MM-DD (defaults to today) - output_format: Force output format ('PNG', 'BMP') - LSB mode only - embed_mode: Embedding mode - 'lsb' (default) or 'dct' (v3.0+) - dct_output_format: For DCT mode - 'png' (lossless) or 'jpeg' (smaller) - dct_color_mode: For DCT mode - 'grayscale' (default) or 'color' (preserves colors) - - Returns: - EncodeResult with stego image and metadata - - Raises: - ValidationError: If inputs are invalid - SecurityFactorError: If no PIN or RSA key provided - CapacityError: If carrier is too small - EncryptionError: If encryption fails - ImportError: If DCT mode requested but scipy unavailable - - Example: - # Default LSB mode - >>> result = encode(message="Secret", ...) - - # DCT mode with grayscale PNG output (default) - >>> result = encode(message="Secret", ..., embed_mode='dct') - - # DCT mode with color JPEG output - >>> result = encode(message="Secret", ..., embed_mode='dct', - ... dct_output_format='jpeg', dct_color_mode='color') - """ - # Debug logging - debug.print(f"encode called: message type={type(message).__name__}, " - f"day_phrase='{day_phrase[:20]}...', pin_length={len(pin)}, " - f"embed_mode={embed_mode}, dct_output_format={dct_output_format}, " - f"dct_color_mode={dct_color_mode}") - - # Validate embed_mode - if embed_mode not in (EMBED_MODE_LSB, EMBED_MODE_DCT): - raise ValidationError(f"Invalid embed_mode: {embed_mode}. Use 'lsb' or 'dct'") - - if embed_mode == EMBED_MODE_DCT and not has_dct_support(): - raise ImportError( - "DCT embedding mode requires scipy. " - "Install with: pip install scipy" - ) - - # Validate dct_output_format - if dct_output_format not in ('png', 'jpeg'): - debug.print(f"Invalid dct_output_format '{dct_output_format}', defaulting to 'png'") - dct_output_format = 'png' - - # Validate dct_color_mode (v3.0.1) - if dct_color_mode not in ('grayscale', 'color'): - debug.print(f"Invalid dct_color_mode '{dct_color_mode}', defaulting to 'grayscale'") - dct_color_mode = 'grayscale' - - # Validate inputs - require_valid_payload(message) - 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) - - # Default date to today - if date_str is None: - date_str = date.today().isoformat() - - debug.print(f"Encoding for date: {date_str}") - - # Encrypt message/file - encrypted = encrypt_message( - message, reference_photo, day_phrase, date_str, pin, rsa_key_data - ) - - # Debug: show encrypted data size - debug.print(f"Encrypted payload: {len(encrypted)} bytes") - - # Get pixel key - pixel_key = derive_pixel_key( - reference_photo, day_phrase, date_str, pin, rsa_key_data - ) - - debug.data(pixel_key, "Pixel key") - - # Embed in image (returns extension too) - # CRITICAL: Pass dct_output_format and dct_color_mode to 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, # NEW in v3.0.1 - dct_color_mode=dct_color_mode, # NEW in v3.0.1 - ) - - # Generate filename with correct extension - filename = generate_filename(date_str, extension=extension) - - # Handle stats from either LSB or DCT mode - if hasattr(stats, 'pixels_modified'): - # LSB mode stats - debug.print(f"Encoding complete: {filename}, " - f"modified {stats.pixels_modified}/{stats.total_pixels} pixels " - f"({stats.modification_percent:.2f}%)") - - 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=date_str - ) - else: - # DCT mode stats - debug.print(f"Encoding complete (DCT): {filename}, " - f"embedded {stats.bits_embedded // 8} bytes " - f"({stats.usage_percent:.2f}% capacity)") - - return EncodeResult( - stego_image=stego_data, - filename=filename, - pixels_modified=stats.blocks_used * 64, # Approximate - total_pixels=stats.blocks_available * 64, - capacity_used=stats.usage_percent / 100.0, - date_used=date_str - ) - - -# ============================================================================= -# ENCODE_FILE FUNCTION (v3.0.1 - with dct_output_format) -# ============================================================================= - -def encode_file( - filepath: Union[str, Path], - reference_photo: bytes, - carrier_image: bytes, - day_phrase: str, - pin: str = "", - rsa_key_data: Optional[bytes] = None, - rsa_password: Optional[str] = None, - date_str: Optional[str] = None, - output_format: Optional[str] = None, - filename_override: Optional[str] = None, - embed_mode: str = EMBED_MODE_LSB, - dct_output_format: str = "png", # NEW in v3.0.1 - dct_color_mode: str = "grayscale", # NEW in v3.0.1 -) -> EncodeResult: - """ - Encode a file into an image. - - Convenience function for embedding files. Preserves original filename. - - Args: - filepath: Path to file to embed - reference_photo: Shared reference photo bytes - carrier_image: Image to hide file in - day_phrase: Today's passphrase - pin: Static PIN (optional if using RSA key) - rsa_key_data: RSA private key PEM bytes (optional if using PIN) - rsa_password: Password for RSA key if encrypted - date_str: Date string YYYY-MM-DD (defaults to today) - output_format: Force output format ('PNG', 'BMP') - LSB mode only - filename_override: Override the stored filename - embed_mode: 'lsb' (default) or 'dct' (v3.0+) - dct_output_format: For DCT mode - 'png' or 'jpeg' (v3.0.1+) - dct_color_mode: For DCT mode - 'grayscale' or 'color' (v3.0.1+) - - Returns: - EncodeResult with stego image and metadata - """ - debug.print(f"encode_file called: filepath={filepath}, embed_mode={embed_mode}, " - f"dct_output_format={dct_output_format}, dct_color_mode={dct_color_mode}") - payload = FilePayload.from_file(str(filepath), filename_override) - - return encode( - message=payload, - reference_photo=reference_photo, - carrier_image=carrier_image, - day_phrase=day_phrase, - pin=pin, - rsa_key_data=rsa_key_data, - rsa_password=rsa_password, - date_str=date_str, - output_format=output_format, - embed_mode=embed_mode, - dct_output_format=dct_output_format, # NEW in v3.0.1 - dct_color_mode=dct_color_mode, # NEW in v3.0.1 - ) - - -# ============================================================================= -# ENCODE_BYTES FUNCTION (v3.0.1 - with dct_output_format) -# ============================================================================= - -def encode_bytes( - data: bytes, - filename: str, - reference_photo: bytes, - carrier_image: bytes, - day_phrase: str, - pin: str = "", - rsa_key_data: Optional[bytes] = None, - rsa_password: Optional[str] = None, - date_str: Optional[str] = None, - output_format: Optional[str] = None, - mime_type: Optional[str] = None, - embed_mode: str = EMBED_MODE_LSB, - dct_output_format: str = "png", # NEW in v3.0.1 - dct_color_mode: str = "grayscale", # NEW in v3.0.1 -) -> EncodeResult: - """ - Encode raw bytes with a filename into an image. - - Convenience function for embedding binary data with metadata. - - Args: - data: Raw bytes to embed - filename: Filename to associate with the data - reference_photo: Shared reference photo bytes - carrier_image: Image to hide data in - day_phrase: Today's passphrase - pin: Static PIN (optional if using RSA key) - rsa_key_data: RSA private key PEM bytes (optional if using PIN) - rsa_password: Password for RSA key if encrypted - date_str: Date string YYYY-MM-DD (defaults to today) - output_format: Force output format ('PNG', 'BMP') - LSB mode only - mime_type: MIME type of the data - embed_mode: 'lsb' (default) or 'dct' (v3.0+) - dct_output_format: For DCT mode - 'png' or 'jpeg' (v3.0.1+) - dct_color_mode: For DCT mode - 'grayscale' or 'color' (v3.0.1+) - - Returns: - EncodeResult with stego image and metadata - """ - debug.print(f"encode_bytes called: filename={filename}, data_size={len(data)}, " - f"embed_mode={embed_mode}, dct_output_format={dct_output_format}, " - f"dct_color_mode={dct_color_mode}") - payload = FilePayload(data=data, filename=filename, mime_type=mime_type) - - return encode( - message=payload, - reference_photo=reference_photo, - carrier_image=carrier_image, - day_phrase=day_phrase, - pin=pin, - rsa_key_data=rsa_key_data, - rsa_password=rsa_password, - date_str=date_str, - output_format=output_format, - embed_mode=embed_mode, - dct_output_format=dct_output_format, # NEW in v3.0.1 - dct_color_mode=dct_color_mode, # NEW in v3.0.1 - ) - - -# ============================================================================= -# DECODE FUNCTION -# ============================================================================= - -@debug.time -def decode( - stego_image: bytes, - reference_photo: bytes, - day_phrase: str, - pin: str = "", - rsa_key_data: Optional[bytes] = None, - rsa_password: Optional[str] = None, - date_str: Optional[str] = None, - embed_mode: str = EMBED_MODE_AUTO, -) -> DecodeResult: - """ - Decode a secret message or file from a stego image. - - High-level convenience function that handles extraction - and decryption in one call. - - Args: - stego_image: Image containing hidden message/file - reference_photo: Shared reference photo bytes - day_phrase: Passphrase for the day message was encoded - pin: Static PIN (if used during encoding) - rsa_key_data: RSA private key PEM bytes (if used during encoding) - rsa_password: Password for RSA key if encrypted - date_str: Date override (defaults to today, then checks header) - embed_mode: 'auto' (default), 'lsb', or 'dct' (v3.0+) - - 'auto': Try LSB first, then DCT if available - - 'lsb': Only try LSB extraction - - 'dct': Only try DCT extraction (requires scipy) - - Returns: - DecodeResult with: - - .payload_type: 'text' or 'file' - - .message: Decoded text (if text) - - .file_data: Decoded bytes (if file) - - .filename: Original filename (if file) - - .is_text / .is_file: Convenience properties - - Raises: - ValidationError: If inputs are invalid - SecurityFactorError: If no PIN or RSA key provided - ExtractionError: If data cannot be extracted - DecryptionError: If decryption fails - ImportError: If DCT mode explicitly requested but scipy unavailable - - Note: - With embed_mode='auto' (default), tries LSB first then DCT. - For best performance, specify the mode if you know it. - """ - debug.print(f"decode called: stego_image_size={len(stego_image)}, " - f"day_phrase='{day_phrase[:20]}...', embed_mode={embed_mode}") - - # Validate embed_mode - if embed_mode not in (EMBED_MODE_AUTO, EMBED_MODE_LSB, EMBED_MODE_DCT): - raise ValidationError(f"Invalid embed_mode: {embed_mode}. Use 'auto', 'lsb', or 'dct'") - - if embed_mode == EMBED_MODE_DCT and not has_dct_support(): - raise ImportError( - "DCT extraction mode requires scipy. " - "Install with: pip install scipy" - ) - - # Validate inputs - 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) - - # Try to extract with today's date first - # Use provided date or fall back to today - if date_str is None: - date_str = date.today().isoformat() - pixel_key = derive_pixel_key( - reference_photo, day_phrase, date_str, pin, rsa_key_data - ) - - debug.data(pixel_key, "Pixel key for extraction") - - # Extract with specified mode - encrypted = extract_from_image( - stego_image, - pixel_key, - embed_mode=embed_mode, - ) - - # If we got data, check if it's from a different date - if encrypted: - header = parse_header(encrypted) - if header and header['date'] != date_str: - debug.print(f"Found different date in header: {header['date']} (expected {date_str})") - # Re-extract with correct date - pixel_key = derive_pixel_key( - reference_photo, day_phrase, header['date'], pin, rsa_key_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 inputs.") - - debug.print(f"Extracted {len(encrypted)} bytes from image") - debug.data(encrypted[:64], "First 64 bytes of extracted data") - - # Decrypt and return full result - return decrypt_message(encrypted, reference_photo, day_phrase, pin, rsa_key_data) - - -# ============================================================================= -# DECODE_TEXT FUNCTION -# ============================================================================= - -def decode_text( - stego_image: bytes, - reference_photo: bytes, - day_phrase: str, - pin: str = "", - rsa_key_data: Optional[bytes] = None, - rsa_password: Optional[str] = None, - date_str: Optional[str] = None, - embed_mode: str = EMBED_MODE_AUTO, -) -> str: - """ - Decode a text message from a stego image. - - Convenience function that returns just the text string. - Raises an error if the content is a binary file. - - Args: - stego_image: Image containing hidden message - reference_photo: Shared reference photo bytes - day_phrase: Passphrase for the day message was encoded - pin: Static PIN (if used during encoding) - rsa_key_data: RSA private key PEM bytes (if used during encoding) - rsa_password: Password for RSA key if encrypted - date_str: Date override - embed_mode: 'auto' (default), 'lsb', or 'dct' (v3.0+) - - Returns: - Decrypted message string - - Raises: - DecryptionError: If content is a binary file, not text - """ - debug.print(f"decode_text called, embed_mode={embed_mode}") - result = decode( - stego_image, - reference_photo, - day_phrase, - pin, - rsa_key_data, - rsa_password, - date_str, - embed_mode, - ) - - if result.is_file: - # Try to decode file as text - if result.file_data: - try: - return result.file_data.decode('utf-8') - except UnicodeDecodeError: - debug.print(f"File is binary: {result.filename or 'unnamed'}") - raise DecryptionError( - f"Content is a binary file ({result.filename or 'unnamed'}), not text. " - "Use decode() instead and check result.is_file." - ) - return "" - - debug.print(f"Decoded text: {result.message[:100] if result.message else 'empty'}...") - message: str = result.message if result.message is not None else "" - return message - - -# ============================================================================= -# EXPORTS -# ============================================================================= +# Aliases for backward compatibility +MIN_MESSAGE_LENGTH = 1 +MAX_MESSAGE_LENGTH = MAX_MESSAGE_SIZE +MAX_PAYLOAD_SIZE = MAX_MESSAGE_SIZE +SUPPORTED_IMAGE_FORMATS = LOSSLESS_FORMATS +LSB_BYTES_PER_PIXEL = 3 / 8 +DCT_BYTES_PER_PIXEL = 0.125 __all__ = [ # Version - '__version__', + "__version__", - # High-level API - 'encode', - 'encode_file', - 'encode_bytes', - 'decode', - 'decode_text', - 'generate_credentials', + # Core + "encode", + "decode", + "decode_file", - # NEW in v3.0 - Embedding modes - 'EMBED_MODE_LSB', - 'EMBED_MODE_DCT', - 'EMBED_MODE_AUTO', - 'has_dct_support', - 'compare_modes', - 'get_available_modes', - 'calculate_capacity_by_mode', - 'will_fit_by_mode', - 'detect_stego_mode', - 'HAS_DCT', + # Generation + "generate_pin", + "generate_passphrase", + "generate_rsa_key", + "generate_credentials", + "export_rsa_key_pem", + "load_rsa_key", - # NEW in v3.0 - DCT functions (available if scipy installed) - 'embed_in_dct', - 'extract_from_dct', - 'calculate_dct_capacity', - 'will_fit_dct', - 'estimate_capacity_comparison', - 'DCTEmbedStats', - 'DCTCapacityInfo', - - # Constants - 'DAY_NAMES', - 'LOSSLESS_FORMATS', - 'MAX_MESSAGE_SIZE', - 'MAX_FILE_PAYLOAD_SIZE', - - # Models - 'Credentials', - 'EncodeInput', - 'EncodeResult', - 'DecodeInput', - 'DecodeResult', - 'EmbedStats', - 'KeyInfo', - 'ValidationResult', - 'FilePayload', - - # Exceptions - 'StegasooError', - 'ValidationError', - 'PinValidationError', - 'MessageValidationError', - 'ImageValidationError', - 'KeyValidationError', - 'SecurityFactorError', - 'CryptoError', - 'EncryptionError', - 'DecryptionError', - 'KeyDerivationError', - 'KeyGenerationError', - 'KeyPasswordError', - 'SteganographyError', - 'CapacityError', - 'ExtractionError', - 'EmbeddingError', - 'InvalidHeaderError', - - # Key generation - 'generate_pin', - 'generate_phrase', - 'generate_day_phrases', - 'generate_rsa_key', - 'export_rsa_key_pem', - 'load_rsa_key', - 'get_key_info', - - # Validation - 'validate_pin', - 'validate_message', - 'validate_payload', - 'validate_file_payload', - 'validate_image', - 'validate_rsa_key', - 'validate_security_factors', - 'validate_phrase', - 'validate_date_string', - 'require_valid_pin', - 'require_valid_message', - 'require_valid_payload', - 'require_valid_image', - 'require_valid_rsa_key', - 'require_security_factors', - - # Crypto - 'encrypt_message', - 'decrypt_message', - 'decrypt_message_text', - 'derive_hybrid_key', - 'derive_pixel_key', - 'hash_photo', - 'parse_header', - 'get_date_from_encrypted', - 'has_argon2', - - # Steganography - 'embed_in_image', - 'extract_from_image', - 'calculate_capacity', - 'get_image_dimensions', - 'get_image_format', - 'is_lossless_format', - 'will_fit', + # Image utilities + "get_image_info", + "compare_capacity", # Utilities - 'generate_filename', - 'parse_date_from_filename', - 'get_day_from_date', - 'get_today_date', - 'get_today_day', - 'secure_delete', - 'SecureDeleter', - 'format_file_size', - 'strip_image_metadata', + "generate_filename", - # Debugging - 'debug', + # Crypto + "has_argon2", - # Compression - 'compress', - 'decompress', - 'CompressionAlgorithm', - 'CompressionError', - 'get_compression_ratio', - 'estimate_compressed_size', - 'get_available_algorithms', + # Steganography + "has_dct_support", + "compare_modes", + "will_fit_by_mode", - # Batch processing - 'BatchProcessor', - 'BatchResult', - 'BatchItem', - 'BatchStatus', - 'batch_capacity_check', - 'BatchCredentials', + # QR utilities + "generate_qr_code", + "extract_key_from_qr", + "HAS_QR_UTILS", + + # Validation + "validate_reference_photo", + "validate_carrier", + "validate_message", + "validate_file_payload", + "validate_passphrase", + "validate_pin", + "validate_rsa_key", + "validate_security_factors", + "validate_embed_mode", + "validate_dct_output_format", + "validate_dct_color_mode", + + # Models + "ImageInfo", + "CapacityComparison", + "GenerateResult", + "EncodeResult", + "DecodeResult", + "FilePayload", + "Credentials", + "ValidationResult", + + # Exceptions + "StegasooError", + "ValidationError", + "PinValidationError", + "MessageValidationError", + "ImageValidationError", + "KeyValidationError", + "SecurityFactorError", + "CryptoError", + "EncryptionError", + "DecryptionError", + "KeyDerivationError", + "KeyGenerationError", + "KeyPasswordError", + "SteganographyError", + "CapacityError", + "ExtractionError", + "EmbeddingError", + "InvalidHeaderError", + + # Constants + "FORMAT_VERSION", + "MIN_PASSPHRASE_WORDS", + "RECOMMENDED_PASSPHRASE_WORDS", + "DEFAULT_PASSPHRASE_WORDS", + "MAX_PASSPHRASE_WORDS", + "MIN_PIN_LENGTH", + "MAX_PIN_LENGTH", + "MIN_MESSAGE_LENGTH", + "MAX_MESSAGE_LENGTH", + "MAX_MESSAGE_SIZE", + "MAX_PAYLOAD_SIZE", + "MIN_IMAGE_PIXELS", + "MAX_IMAGE_PIXELS", + "SUPPORTED_IMAGE_FORMATS", + "LOSSLESS_FORMATS", + "LSB_BYTES_PER_PIXEL", + "DCT_BYTES_PER_PIXEL", + "EMBED_MODE_LSB", + "EMBED_MODE_DCT", + "EMBED_MODE_AUTO", ] diff --git a/src/stegasoo/channel.py b/src/stegasoo/channel.py new file mode 100644 index 0000000..06e8df5 --- /dev/null +++ b/src/stegasoo/channel.py @@ -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 - 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 ") + 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) diff --git a/src/stegasoo/constants.py b/src/stegasoo/constants.py index 0d85566..3a33ea1 100644 --- a/src/stegasoo/constants.py +++ b/src/stegasoo/constants.py @@ -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: """ diff --git a/src/stegasoo/crypto.py b/src/stegasoo/crypto.py index 4f21099..b3ff338 100644 --- a/src/stegasoo/crypto.py +++ b/src/stegasoo/crypto.py @@ -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 diff --git a/src/stegasoo/decode.py b/src/stegasoo/decode.py new file mode 100644 index 0000000..498575e --- /dev/null +++ b/src/stegasoo/decode.py @@ -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 "" diff --git a/src/stegasoo/encode.py b/src/stegasoo/encode.py new file mode 100644 index 0000000..8ea6f73 --- /dev/null +++ b/src/stegasoo/encode.py @@ -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, + ) diff --git a/src/stegasoo/generate.py b/src/stegasoo/generate.py new file mode 100644 index 0000000..3d81f54 --- /dev/null +++ b/src/stegasoo/generate.py @@ -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 diff --git a/src/stegasoo/image_utils.py b/src/stegasoo/image_utils.py new file mode 100644 index 0000000..7577572 --- /dev/null +++ b/src/stegasoo/image_utils.py @@ -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, + } diff --git a/src/stegasoo/models.py b/src/stegasoo/models.py index 747de21..57d7e50 100644 --- a/src/stegasoo/models.py +++ b/src/stegasoo/models.py @@ -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) diff --git a/src/stegasoo/validation.py b/src/stegasoo/validation.py index 9917d77..8c67026 100644 --- a/src/stegasoo/validation.py +++ b/src/stegasoo/validation.py @@ -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()) # ============================================================================