diff --git a/frontends/api/main.py b/frontends/api/main.py index fbf0e8b..c2a3b63 100644 --- a/frontends/api/main.py +++ b/frontends/api/main.py @@ -31,7 +31,7 @@ from fastapi.responses import JSONResponse, Response from pydantic import BaseModel, Field # Add parent to path for development -sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src')) +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) from stegasoo import ( MAX_FILE_PAYLOAD_SIZE, @@ -71,6 +71,7 @@ try: extract_key_from_qr, has_qr_read, ) + HAS_QR_READ = has_qr_read() except ImportError: HAS_QR_READ = False @@ -130,6 +131,7 @@ DctOutputFormatType = Literal["png", "jpeg"] # MODELS # ============================================================================ + class GenerateRequest(BaseModel): use_pin: bool = True use_rsa: bool = False @@ -139,7 +141,7 @@ class GenerateRequest(BaseModel): default=DEFAULT_PASSPHRASE_WORDS, ge=MIN_PASSPHRASE_WORDS, le=MAX_PASSPHRASE_WORDS, - description="Words per passphrase (v3.2.0: default increased to 4)" + description="Words per passphrase (v3.2.0: default increased to 4)", ) @@ -150,8 +152,7 @@ class GenerateResponse(BaseModel): entropy: dict[str, int] # Legacy field for compatibility phrases: dict[str, str] | None = Field( - default=None, - description="Deprecated: Use 'passphrase' instead" + default=None, description="Deprecated: Use 'passphrase' instead" ) @@ -166,24 +167,25 @@ class EncodeRequest(BaseModel): # Channel key (v4.0.0) channel_key: str | None = Field( default=None, - description="Channel key for deployment isolation. null=auto (use server config), ''=public mode, 'XXXX-...'=explicit key" + description="Channel key for deployment isolation. null=auto (use server config), ''=public mode, 'XXXX-...'=explicit key", ) embed_mode: EmbedModeType = Field( default="lsb", - description="Embedding mode: 'lsb' (default, color) or 'dct' (requires scipy)" + description="Embedding mode: 'lsb' (default, color) or 'dct' (requires scipy)", ) dct_output_format: DctOutputFormatType = Field( default="png", - description="DCT output format: 'png' (lossless) or 'jpeg' (smaller). Only applies to DCT mode." + description="DCT output format: 'png' (lossless) or 'jpeg' (smaller). Only applies to DCT mode.", ) dct_color_mode: DctColorModeType = Field( default="grayscale", - description="DCT color mode: 'grayscale' (default) or 'color' (preserves colors). Only applies to DCT mode." + description="DCT color mode: 'grayscale' (default) or 'color' (preserves colors). Only applies to DCT mode.", ) class EncodeFileRequest(BaseModel): """Request for embedding a file (base64-encoded).""" + file_data_base64: str filename: str mime_type: str | None = None @@ -196,19 +198,19 @@ class EncodeFileRequest(BaseModel): # Channel key (v4.0.0) channel_key: str | None = Field( default=None, - description="Channel key for deployment isolation. null=auto (use server config), ''=public mode, 'XXXX-...'=explicit key" + description="Channel key for deployment isolation. null=auto (use server config), ''=public mode, 'XXXX-...'=explicit key", ) embed_mode: EmbedModeType = Field( default="lsb", - description="Embedding mode: 'lsb' (default, color) or 'dct' (requires scipy)" + description="Embedding mode: 'lsb' (default, color) or 'dct' (requires scipy)", ) dct_output_format: DctOutputFormatType = Field( default="png", - description="DCT output format: 'png' (lossless) or 'jpeg' (smaller). Only applies to DCT mode." + description="DCT output format: 'png' (lossless) or 'jpeg' (smaller). Only applies to DCT mode.", ) dct_color_mode: DctColorModeType = Field( default="grayscale", - description="DCT color mode: 'grayscale' (default) or 'color' (preserves colors). Only applies to DCT mode." + description="DCT color mode: 'grayscale' (default) or 'color' (preserves colors). Only applies to DCT mode.", ) @@ -218,30 +220,23 @@ class EncodeResponse(BaseModel): capacity_used_percent: float embed_mode: str = Field(description="Embedding mode used: 'lsb' or 'dct'") output_format: str = Field( - default="png", - description="Output format: 'png' or 'jpeg' (for DCT mode)" + default="png", description="Output format: 'png' or 'jpeg' (for DCT mode)" ) color_mode: str = Field( default="color", - description="Color mode: 'color' (LSB/DCT color) or 'grayscale' (DCT grayscale)" + description="Color mode: 'color' (LSB/DCT color) or 'grayscale' (DCT grayscale)", ) # Channel key info (v4.0.0) - channel_mode: str = Field( - default="public", - description="Channel mode: 'public' or 'private'" - ) + channel_mode: str = Field(default="public", description="Channel mode: 'public' or 'private'") channel_fingerprint: str | None = Field( - default=None, - description="Channel key fingerprint (if private mode)" + default=None, description="Channel key fingerprint (if private mode)" ) # Legacy fields (v3.2.0: no longer used in crypto) date_used: str | None = Field( - default=None, - description="Deprecated: Date no longer used in v3.2.0" + default=None, description="Deprecated: Date no longer used in v3.2.0" ) day_of_week: str | None = Field( - default=None, - description="Deprecated: Date no longer used in v3.2.0" + default=None, description="Deprecated: Date no longer used in v3.2.0" ) @@ -255,16 +250,16 @@ class DecodeRequest(BaseModel): # Channel key (v4.0.0) channel_key: str | None = Field( default=None, - description="Channel key for decryption. null=auto (use server config), ''=public mode, 'XXXX-...'=explicit key" + description="Channel key for decryption. null=auto (use server config), ''=public mode, 'XXXX-...'=explicit key", ) embed_mode: ExtractModeType = Field( - default="auto", - description="Extraction mode: 'auto' (default), 'lsb', or 'dct'" + default="auto", description="Extraction mode: 'auto' (default), 'lsb', or 'dct'" ) class DecodeResponse(BaseModel): """Response for decode - can be text or file.""" + payload_type: str # 'text' or 'file' message: str | None = None # For text file_data_base64: str | None = None # For file (base64-encoded) @@ -274,6 +269,7 @@ class DecodeResponse(BaseModel): class ModeCapacity(BaseModel): """Capacity info for a single mode.""" + capacity_bytes: int capacity_kb: float available: bool @@ -287,22 +283,22 @@ class ImageInfoResponse(BaseModel): capacity_bytes: int = Field(description="LSB mode capacity (for backwards compatibility)") capacity_kb: int = Field(description="LSB mode capacity in KB") modes: dict[str, ModeCapacity] | None = Field( - default=None, - description="Capacity by embedding mode (v3.0+)" + default=None, description="Capacity by embedding mode (v3.0+)" ) class CompareModesRequest(BaseModel): """Request for comparing embedding modes.""" + carrier_image_base64: str payload_size: int | None = Field( - default=None, - description="Optional payload size to check if it fits" + default=None, description="Optional payload size to check if it fits" ) class CompareModesResponse(BaseModel): """Response comparing LSB and DCT modes.""" + width: int height: int lsb: dict @@ -313,6 +309,7 @@ class CompareModesResponse(BaseModel): class DctModeInfo(BaseModel): """Detailed DCT mode information.""" + available: bool name: str description: str @@ -324,6 +321,7 @@ class DctModeInfo(BaseModel): class ChannelStatusResponse(BaseModel): """Response for channel key status (v4.0.0).""" + mode: str = Field(description="'public' or 'private'") configured: bool = Field(description="Whether a channel key is configured") fingerprint: str | None = Field(default=None, description="Key fingerprint (partial)") @@ -333,6 +331,7 @@ class ChannelStatusResponse(BaseModel): class ChannelGenerateResponse(BaseModel): """Response for channel key generation (v4.0.0).""" + key: str = Field(description="Generated channel key") fingerprint: str = Field(description="Key fingerprint") saved: bool = Field(default=False, description="Whether key was saved to config") @@ -341,19 +340,18 @@ class ChannelGenerateResponse(BaseModel): class ChannelSetRequest(BaseModel): """Request to set channel key (v4.0.0).""" + key: str = Field(description="Channel key to set") location: str = Field(default="user", description="'user' or 'project'") class ModesResponse(BaseModel): """Response showing available embedding modes.""" + lsb: dict dct: DctModeInfo # Channel key status (v4.0.0) - channel: dict | None = Field( - default=None, - description="Channel key status (v4.0.0)" - ) + channel: dict | None = Field(default=None, description="Channel key status (v4.0.0)") class StatusResponse(BaseModel): @@ -363,18 +361,10 @@ class StatusResponse(BaseModel): has_dct: bool max_payload_kb: int available_modes: list[str] - dct_features: dict | None = Field( - default=None, - description="DCT mode features (v3.0.1+)" - ) + dct_features: dict | None = Field(default=None, description="DCT mode features (v3.0.1+)") # Channel key status (v4.0.0) - channel: dict | None = Field( - default=None, - description="Channel key status (v4.0.0)" - ) - breaking_changes: dict = Field( - description="v4.0.0 breaking changes" - ) + channel: dict | None = Field(default=None, description="Channel key status (v4.0.0)") + breaking_changes: dict = Field(description="v4.0.0 breaking changes") class QrExtractResponse(BaseModel): @@ -385,6 +375,7 @@ class QrExtractResponse(BaseModel): class WillFitRequest(BaseModel): """Request to check if payload will fit.""" + carrier_image_base64: str payload_size: int embed_mode: EmbedModeType = "lsb" @@ -392,6 +383,7 @@ class WillFitRequest(BaseModel): class WillFitResponse(BaseModel): """Response for will_fit check.""" + fits: bool payload_size: int capacity: int @@ -409,6 +401,7 @@ class ErrorResponse(BaseModel): # HELPER: RESOLVE CHANNEL KEY # ============================================================================ + def _resolve_channel_key(channel_key: str | None) -> str | None: """ Resolve channel key from API parameter. @@ -436,8 +429,7 @@ def _resolve_channel_key(channel_key: str | None) -> str | None: # Explicit key - validate format if not validate_channel_key(channel_key): raise HTTPException( - 400, - "Invalid channel key format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" + 400, "Invalid channel key format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" ) return channel_key @@ -461,7 +453,7 @@ def _get_channel_info(channel_key: str | None) -> tuple[str, str | None]: # Auto mode - check server config if has_channel_key(): status = get_channel_status() - return "private", status.get('fingerprint') + return "private", status.get("fingerprint") return "public", None @@ -470,6 +462,7 @@ def _get_channel_info(channel_key: str | None) -> tuple[str, str | None]: # ROUTES - STATUS & INFO # ============================================================================ + @app.get("/", response_model=StatusResponse) async def root(): """Get API status and configuration.""" @@ -488,10 +481,10 @@ async def root(): # Channel key status (v4.0.0) channel_status = get_channel_status() channel_info = { - "mode": channel_status['mode'], - "configured": channel_status['configured'], - "fingerprint": channel_status.get('fingerprint'), - "source": channel_status.get('source'), + "mode": channel_status["mode"], + "configured": channel_status["configured"], + "fingerprint": channel_status.get("fingerprint"), + "source": channel_status.get("source"), } return StatusResponse( @@ -510,8 +503,8 @@ async def root(): "v3_notes": { "date_removed": "No date_str parameter needed - encode/decode anytime", "passphrase_renamed": "day_phrase → passphrase (single passphrase, no daily rotation)", - } - } + }, + }, ) @@ -525,9 +518,9 @@ async def api_modes(): # Channel status channel_status = get_channel_status() channel_info = { - "mode": channel_status['mode'], - "configured": channel_status['configured'], - "fingerprint": channel_status.get('fingerprint'), + "mode": channel_status["mode"], + "configured": channel_status["configured"], + "fingerprint": channel_status.get("fingerprint"), } return ModesResponse( @@ -555,6 +548,7 @@ async def api_modes(): # ROUTES - CHANNEL KEY (v4.0.0) # ============================================================================ + @app.get("/channel/status", response_model=ChannelStatusResponse) async def api_channel_status( reveal: bool = Query(False, description="Include full key in response") @@ -570,11 +564,11 @@ async def api_channel_status( status = get_channel_status() return ChannelStatusResponse( - mode=status['mode'], - configured=status['configured'], - fingerprint=status.get('fingerprint'), - source=status.get('source'), - key=status.get('key') if reveal and status['configured'] else None, + mode=status["mode"], + configured=status["configured"], + fingerprint=status.get("fingerprint"), + source=status.get("source"), + key=status.get("key") if reveal and status["configured"] else None, ) @@ -601,11 +595,11 @@ async def api_channel_generate( save_location = None if save: - set_channel_key(key, location='user') + set_channel_key(key, location="user") saved = True save_location = "~/.stegasoo/channel.key" elif save_project: - set_channel_key(key, location='project') + set_channel_key(key, location="project") saved = True save_location = "./config/channel.key" @@ -626,11 +620,10 @@ async def api_channel_set(request: ChannelSetRequest): """ if not validate_channel_key(request.key): raise HTTPException( - 400, - "Invalid channel key format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" + 400, "Invalid channel key format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" ) - if request.location not in ('user', 'project'): + if request.location not in ("user", "project"): raise HTTPException(400, "location must be 'user' or 'project'") set_channel_key(request.key, location=request.location) @@ -638,8 +631,8 @@ async def api_channel_set(request: ChannelSetRequest): status = get_channel_status() return { "success": True, - "location": status.get('source'), - "fingerprint": status.get('fingerprint'), + "location": status.get("source"), + "fingerprint": status.get("fingerprint"), } @@ -655,9 +648,9 @@ async def api_channel_clear( Note: Does not affect environment variables. """ if location == "all": - clear_channel_key(location='user') - clear_channel_key(location='project') - elif location in ('user', 'project'): + clear_channel_key(location="user") + clear_channel_key(location="project") + elif location in ("user", "project"): clear_channel_key(location=location) else: raise HTTPException(400, "location must be 'user', 'project', or 'all'") @@ -665,9 +658,9 @@ async def api_channel_clear( status = get_channel_status() return { "success": True, - "mode": status['mode'], - "still_configured": status['configured'], - "remaining_source": status.get('source'), + "mode": status["mode"], + "still_configured": status["configured"], + "remaining_source": status.get("source"), } @@ -684,28 +677,30 @@ async def api_compare_modes(request: CompareModesRequest): comparison = compare_modes(carrier) response = CompareModesResponse( - width=comparison['width'], - height=comparison['height'], + width=comparison["width"], + height=comparison["height"], lsb={ - "capacity_bytes": comparison['lsb']['capacity_bytes'], - "capacity_kb": round(comparison['lsb']['capacity_kb'], 1), + "capacity_bytes": comparison["lsb"]["capacity_bytes"], + "capacity_kb": round(comparison["lsb"]["capacity_kb"], 1), "available": True, - "output_format": comparison['lsb']['output'], + "output_format": comparison["lsb"]["output"], }, dct={ - "capacity_bytes": comparison['dct']['capacity_bytes'], - "capacity_kb": round(comparison['dct']['capacity_kb'], 1), - "available": comparison['dct']['available'], + "capacity_bytes": comparison["dct"]["capacity_bytes"], + "capacity_kb": round(comparison["dct"]["capacity_kb"], 1), + "available": comparison["dct"]["available"], "output_formats": ["png", "jpeg"], "color_modes": ["grayscale", "color"], - "ratio_vs_lsb_percent": round(comparison['dct']['ratio_vs_lsb'], 1), + "ratio_vs_lsb_percent": round(comparison["dct"]["ratio_vs_lsb"], 1), }, - recommendation="lsb" if not comparison['dct']['available'] else "dct for stealth, lsb for capacity" + recommendation=( + "lsb" if not comparison["dct"]["available"] else "dct for stealth, lsb for capacity" + ), ) if request.payload_size: - fits_lsb = request.payload_size <= comparison['lsb']['capacity_bytes'] - fits_dct = request.payload_size <= comparison['dct']['capacity_bytes'] + fits_lsb = request.payload_size <= comparison["lsb"]["capacity_bytes"] + fits_dct = request.payload_size <= comparison["dct"]["capacity_bytes"] response.payload_check = { "size_bytes": request.payload_size, @@ -714,7 +709,7 @@ async def api_compare_modes(request: CompareModesRequest): } # Update recommendation based on payload - if fits_dct and comparison['dct']['available']: + if fits_dct and comparison["dct"]["available"]: response.recommendation = "dct (payload fits, better stealth)" elif fits_lsb: response.recommendation = "lsb (payload too large for dct)" @@ -743,12 +738,12 @@ async def api_will_fit(request: WillFitRequest): result = will_fit_by_mode(request.payload_size, carrier, embed_mode=request.embed_mode) return WillFitResponse( - fits=result['fits'], - payload_size=result['payload_size'], - capacity=result['capacity'], - usage_percent=round(result['usage_percent'], 1), - headroom=result['headroom'], - mode=request.embed_mode + fits=result["fits"], + payload_size=result["payload_size"], + capacity=result["capacity"], + usage_percent=round(result["usage_percent"], 1), + headroom=result["headroom"], + mode=request.embed_mode, ) except HTTPException: @@ -761,6 +756,7 @@ async def api_will_fit(request: WillFitRequest): # ROUTES - QR CODE # ============================================================================ + @app.post("/extract-key-from-qr", response_model=QrExtractResponse) async def api_extract_key_from_qr( qr_image: UploadFile = File(..., description="QR code image containing RSA key") @@ -772,10 +768,7 @@ async def api_extract_key_from_qr( Returns the PEM-encoded key if found. """ if not HAS_QR_READ: - raise HTTPException( - 501, - "QR code reading not available. Install pyzbar and libzbar." - ) + raise HTTPException(501, "QR code reading not available. Install pyzbar and libzbar.") try: image_data = await qr_image.read() @@ -784,10 +777,7 @@ async def api_extract_key_from_qr( if key_pem: return QrExtractResponse(success=True, key_pem=key_pem) else: - return QrExtractResponse( - success=False, - error="No valid RSA key found in QR code" - ) + return QrExtractResponse(success=False, error="No valid RSA key found in QR code") except Exception as e: return QrExtractResponse(success=False, error=str(e)) @@ -796,6 +786,7 @@ async def api_extract_key_from_qr( # ROUTES - GENERATE # ============================================================================ + @app.post("/generate", response_model=GenerateResponse) async def api_generate(request: GenerateRequest): """ @@ -829,9 +820,9 @@ async def api_generate(request: GenerateRequest): "passphrase": creds.passphrase_entropy, "pin": creds.pin_entropy, "rsa": creds.rsa_entropy, - "total": creds.total_entropy + "total": creds.total_entropy, }, - phrases=None # Legacy field removed + phrases=None, # Legacy field removed ) except Exception as e: raise HTTPException(500, str(e)) @@ -841,6 +832,7 @@ async def api_generate(request: GenerateRequest): # HELPER FUNCTION FOR DCT PARAMETERS # ============================================================================ + def _get_dct_params(embed_mode: str, dct_output_format: str, dct_color_mode: str) -> dict: """ Get DCT-specific parameters if DCT mode is selected. @@ -876,6 +868,7 @@ def _get_output_info(embed_mode: str, dct_output_format: str, dct_color_mode: st # ROUTES - ENCODE (JSON) # ============================================================================ + @app.post("/encode", response_model=EncodeResponse) async def api_encode(request: EncodeRequest): """ @@ -900,9 +893,7 @@ async def api_encode(request: EncodeRequest): # Get DCT parameters dct_params = _get_dct_params( - request.embed_mode, - request.dct_output_format, - request.dct_color_mode + request.embed_mode, request.dct_output_format, request.dct_color_mode ) # v4.0.0: Include channel_key @@ -919,12 +910,10 @@ async def api_encode(request: EncodeRequest): **dct_params, ) - stego_b64 = base64.b64encode(result.stego_image).decode('utf-8') + stego_b64 = base64.b64encode(result.stego_image).decode("utf-8") output_format, color_mode, _ = _get_output_info( - request.embed_mode, - request.dct_output_format, - request.dct_color_mode + request.embed_mode, request.dct_output_format, request.dct_color_mode ) # Get channel info for response @@ -975,16 +964,12 @@ async def api_encode_file(request: EncodeFileRequest): rsa_key = base64.b64decode(request.rsa_key_base64) if request.rsa_key_base64 else None payload = FilePayload( - data=file_data, - filename=request.filename, - mime_type=request.mime_type + data=file_data, filename=request.filename, mime_type=request.mime_type ) # Get DCT parameters dct_params = _get_dct_params( - request.embed_mode, - request.dct_output_format, - request.dct_color_mode + request.embed_mode, request.dct_output_format, request.dct_color_mode ) # v4.0.0: Include channel_key @@ -1001,12 +986,10 @@ async def api_encode_file(request: EncodeFileRequest): **dct_params, ) - stego_b64 = base64.b64encode(result.stego_image).decode('utf-8') + stego_b64 = base64.b64encode(result.stego_image).decode("utf-8") output_format, color_mode, _ = _get_output_info( - request.embed_mode, - request.dct_output_format, - request.dct_color_mode + request.embed_mode, request.dct_output_format, request.dct_color_mode ) # Get channel info for response @@ -1037,6 +1020,7 @@ async def api_encode_file(request: EncodeFileRequest): # ROUTES - DECODE (JSON) # ============================================================================ + @app.post("/decode", response_model=DecodeResponse) async def api_decode(request: DecodeRequest): """ @@ -1073,21 +1057,18 @@ async def api_decode(request: DecodeRequest): if result.is_file: return DecodeResponse( - payload_type='file', - file_data_base64=base64.b64encode(result.file_data).decode('utf-8'), + payload_type="file", + file_data_base64=base64.b64encode(result.file_data).decode("utf-8"), filename=result.filename, - mime_type=result.mime_type + mime_type=result.mime_type, ) else: - return DecodeResponse( - payload_type='text', - message=result.message - ) + return DecodeResponse(payload_type="text", message=result.message) except DecryptionError as e: # Provide helpful error message for channel key issues error_msg = str(e) - if 'channel key' in error_msg.lower(): + if "channel key" in error_msg.lower(): raise HTTPException(401, error_msg) raise HTTPException(401, "Decryption failed. Check credentials.") except StegasooError as e: @@ -1100,6 +1081,7 @@ async def api_decode(request: DecodeRequest): # ROUTES - ENCODE/DECODE (MULTIPART) # ============================================================================ + @app.post("/encode/multipart") async def api_encode_multipart( passphrase: str = Form(..., description="Passphrase (v3.2.0: renamed from day_phrase)"), @@ -1112,7 +1094,9 @@ async def api_encode_multipart( rsa_key_qr: UploadFile | None = File(None), rsa_password: str = Form(""), # Channel key (v4.0.0) - channel_key: str = Form("auto", description="Channel key: 'auto'=server config, 'none'=public, 'XXXX-...'=explicit"), + channel_key: str = Form( + "auto", description="Channel key: 'auto'=server config, 'none'=public, 'XXXX-...'=explicit" + ), embed_mode: str = Form("lsb"), dct_output_format: str = Form("png"), dct_color_mode: str = Form("grayscale"), @@ -1162,14 +1146,13 @@ async def api_encode_multipart( elif rsa_key_qr and rsa_key_qr.filename: if not HAS_QR_READ: raise HTTPException( - 501, - "QR code reading not available. Install pyzbar and libzbar." + 501, "QR code reading not available. Install pyzbar and libzbar." ) qr_image_data = await rsa_key_qr.read() key_pem = extract_key_from_qr(qr_image_data) if not key_pem: raise HTTPException(400, "Could not extract RSA key from QR code image") - rsa_key_data = key_pem.encode('utf-8') + rsa_key_data = key_pem.encode("utf-8") rsa_key_from_qr = True # QR code keys are never password-protected @@ -1179,9 +1162,7 @@ async def api_encode_multipart( if payload_file and payload_file.filename: file_data = await payload_file.read() payload = FilePayload( - data=file_data, - filename=payload_file.filename, - mime_type=payload_file.content_type + data=file_data, filename=payload_file.filename, mime_type=payload_file.content_type ) elif message: payload = message @@ -1251,7 +1232,9 @@ async def api_decode_multipart( rsa_key_qr: UploadFile | None = File(None), rsa_password: str = Form(""), # Channel key (v4.0.0) - channel_key: str = Form("auto", description="Channel key: 'auto'=server config, 'none'=public, 'XXXX-...'=explicit"), + channel_key: str = Form( + "auto", description="Channel key: 'auto'=server config, 'none'=public, 'XXXX-...'=explicit" + ), embed_mode: str = Form("auto"), ): """ @@ -1291,14 +1274,13 @@ async def api_decode_multipart( elif rsa_key_qr and rsa_key_qr.filename: if not HAS_QR_READ: raise HTTPException( - 501, - "QR code reading not available. Install pyzbar and libzbar." + 501, "QR code reading not available. Install pyzbar and libzbar." ) qr_image_data = await rsa_key_qr.read() key_pem = extract_key_from_qr(qr_image_data) if not key_pem: raise HTTPException(400, "Could not extract RSA key from QR code image") - rsa_key_data = key_pem.encode('utf-8') + rsa_key_data = key_pem.encode("utf-8") rsa_key_from_qr = True # QR code keys are never password-protected @@ -1318,20 +1300,17 @@ async def api_decode_multipart( if result.is_file: return DecodeResponse( - payload_type='file', - file_data_base64=base64.b64encode(result.file_data).decode('utf-8'), + payload_type="file", + file_data_base64=base64.b64encode(result.file_data).decode("utf-8"), filename=result.filename, - mime_type=result.mime_type + mime_type=result.mime_type, ) else: - return DecodeResponse( - payload_type='text', - message=result.message - ) + return DecodeResponse(payload_type="text", message=result.message) except DecryptionError as e: error_msg = str(e) - if 'channel key' in error_msg.lower(): + if "channel key" in error_msg.lower(): raise HTTPException(401, error_msg) raise HTTPException(401, "Decryption failed. Check credentials.") except StegasooError as e: @@ -1346,10 +1325,11 @@ async def api_decode_multipart( # ROUTES - IMAGE INFO # ============================================================================ + @app.post("/image/info", response_model=ImageInfoResponse) async def api_image_info( image: UploadFile = File(...), - include_modes: bool = Query(True, description="Include capacity by mode (v3.0+)") + include_modes: bool = Query(True, description="Include capacity by mode (v3.0+)"), ): """ Get information about an image's capacity. @@ -1363,29 +1343,29 @@ async def api_image_info( if not result.is_valid: raise HTTPException(400, result.error_message) - capacity = calculate_capacity_by_mode(image_data, 'lsb') + capacity = calculate_capacity_by_mode(image_data, "lsb") response = ImageInfoResponse( - width=result.details['width'], - height=result.details['height'], - pixels=result.details['pixels'], + width=result.details["width"], + height=result.details["height"], + pixels=result.details["pixels"], capacity_bytes=capacity, - capacity_kb=capacity // 1024 + capacity_kb=capacity // 1024, ) if include_modes: comparison = compare_modes(image_data) response.modes = { "lsb": ModeCapacity( - capacity_bytes=comparison['lsb']['capacity_bytes'], - capacity_kb=round(comparison['lsb']['capacity_kb'], 1), + capacity_bytes=comparison["lsb"]["capacity_bytes"], + capacity_kb=round(comparison["lsb"]["capacity_kb"], 1), available=True, - output_format=comparison['lsb']['output'], + output_format=comparison["lsb"]["output"], ), "dct": ModeCapacity( - capacity_bytes=comparison['dct']['capacity_bytes'], - capacity_kb=round(comparison['dct']['capacity_kb'], 1), - available=comparison['dct']['available'], + 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)", ), } @@ -1402,18 +1382,17 @@ async def api_image_info( # ERROR HANDLERS # ============================================================================ + @app.exception_handler(StegasooError) async def stegasoo_error_handler(request, exc): - return JSONResponse( - status_code=400, - content={"error": type(exc).__name__, "detail": str(exc)} - ) + return JSONResponse(status_code=400, content={"error": type(exc).__name__, "detail": str(exc)}) # ============================================================================ # MAIN # ============================================================================ -if __name__ == '__main__': +if __name__ == "__main__": import uvicorn + uvicorn.run(app, host="0.0.0.0", port=8000) diff --git a/frontends/cli/main.py b/frontends/cli/main.py index 1c2ebac..b877e9f 100644 --- a/frontends/cli/main.py +++ b/frontends/cli/main.py @@ -30,7 +30,7 @@ from pathlib import Path import click # Add parent to path for development -sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src')) +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) from stegasoo import ( DecryptionError, @@ -90,6 +90,7 @@ except ImportError: # Optional: strip_image_metadata from utils try: from stegasoo.utils import strip_image_metadata + HAS_STRIP_METADATA = True except ImportError: HAS_STRIP_METADATA = False @@ -104,6 +105,7 @@ try: has_qr_write, needs_compression, ) + HAS_QR = True except ImportError: HAS_QR = False @@ -119,11 +121,11 @@ except ImportError: # CLI SETUP # ============================================================================ -CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) +CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) @click.group(context_settings=CONTEXT_SETTINGS) -@click.version_option(__version__, '-v', '--version') +@click.version_option(__version__, "-v", "--version") def cli(): """ Stegasoo - Secure steganography with hybrid authentication. @@ -159,8 +161,10 @@ def cli(): # CHANNEL KEY HELPERS # ============================================================================ -def resolve_channel_key_option(channel: str | None, channel_file: str | None, - no_channel: bool) -> str | None: + +def resolve_channel_key_option( + channel: str | None, channel_file: str | None, no_channel: bool +) -> str | None: """ Resolve channel key from CLI options. @@ -183,7 +187,7 @@ def resolve_channel_key_option(channel: str | None, channel_file: str | None, return key if channel: - if channel.lower() == 'auto': + if channel.lower() == "auto": return None # Use server config # Explicit key provided if not validate_channel_key(channel): @@ -203,7 +207,7 @@ def format_channel_status_line(quiet: bool = False) -> str | None: return None status = get_channel_status() - if status['mode'] == 'public': + if status["mode"] == "public": return None return f"Channel: {status['fingerprint']} ({status['source']})" @@ -213,18 +217,28 @@ def format_channel_status_line(quiet: bool = False) -> str | None: # GENERATE COMMAND # ============================================================================ + @cli.command() -@click.option('--pin/--no-pin', default=True, help='Generate a PIN (default: yes)') -@click.option('--rsa/--no-rsa', default=False, help='Generate an RSA key') -@click.option('--pin-length', type=click.IntRange(6, 9), default=DEFAULT_PIN_LENGTH, - help=f'PIN length (6-9, default: {DEFAULT_PIN_LENGTH})') -@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=DEFAULT_PASSPHRASE_WORDS, - help=f'Words per passphrase (default: {DEFAULT_PASSPHRASE_WORDS})') -@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') +@click.option("--pin/--no-pin", default=True, help="Generate a PIN (default: yes)") +@click.option("--rsa/--no-rsa", default=False, help="Generate an RSA key") +@click.option( + "--pin-length", + type=click.IntRange(6, 9), + default=DEFAULT_PIN_LENGTH, + help=f"PIN length (6-9, default: {DEFAULT_PIN_LENGTH})", +) +@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=DEFAULT_PASSPHRASE_WORDS, + help=f"Words per passphrase (default: {DEFAULT_PASSPHRASE_WORDS})", +) +@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") def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): """ Generate credentials for encoding/decoding. @@ -264,54 +278,55 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): if as_json: import json + data = { - 'passphrase': creds.passphrase, - 'pin': creds.pin, - 'rsa_key': creds.rsa_key_pem, - 'entropy': { - 'passphrase': creds.passphrase_entropy, - 'pin': creds.pin_entropy, - 'rsa': creds.rsa_entropy, - 'total': creds.total_entropy, - } + "passphrase": creds.passphrase, + "pin": creds.pin, + "rsa_key": creds.rsa_key_pem, + "entropy": { + "passphrase": creds.passphrase_entropy, + "pin": creds.pin_entropy, + "rsa": creds.rsa_entropy, + "total": creds.total_entropy, + }, } click.echo(json.dumps(data, indent=2)) return # Pretty output click.echo() - click.secho("=" * 60, fg='cyan') - click.secho(" STEGASOO CREDENTIALS (v4.0.0)", fg='cyan', bold=True) - click.secho("=" * 60, fg='cyan') + click.secho("=" * 60, fg="cyan") + click.secho(" STEGASOO CREDENTIALS (v4.0.0)", fg="cyan", bold=True) + click.secho("=" * 60, fg="cyan") click.echo() - click.secho("⚠️ MEMORIZE THESE AND CLOSE THIS WINDOW", fg='yellow', bold=True) - click.secho(" Do not screenshot or save to file!", fg='yellow') + click.secho("⚠️ MEMORIZE THESE AND CLOSE THIS WINDOW", fg="yellow", bold=True) + click.secho(" Do not screenshot or save to file!", fg="yellow") click.echo() if creds.pin: - click.secho("─── STATIC PIN ───", fg='green') - click.secho(f" {creds.pin}", fg='bright_yellow', bold=True) + click.secho("─── STATIC PIN ───", fg="green") + click.secho(f" {creds.pin}", fg="bright_yellow", bold=True) click.echo() - click.secho("─── PASSPHRASE ───", fg='green') - click.secho(f" {creds.passphrase}", fg='bright_white', bold=True) + click.secho("─── PASSPHRASE ───", fg="green") + click.secho(f" {creds.passphrase}", fg="bright_white", bold=True) click.echo() if creds.rsa_key_pem: - click.secho("─── RSA KEY ───", fg='green') + click.secho("─── RSA KEY ───", fg="green") if output: # Save to file private_key = load_rsa_key(creds.rsa_key_pem.encode()) encrypted_pem = export_rsa_key_pem(private_key, password) Path(output).write_bytes(encrypted_pem) - click.secho(f" Saved to: {output}", fg='bright_white') + click.secho(f" Saved to: {output}", fg="bright_white") click.secho(f" Password: {'*' * len(password)}", dim=True) else: click.echo(creds.rsa_key_pem) click.echo() - click.secho("─── SECURITY ───", fg='green') + click.secho("─── SECURITY ───", fg="green") click.echo(f" Passphrase entropy: {creds.passphrase_entropy} bits ({words} words)") if creds.pin: click.echo(f" PIN entropy: {creds.pin_entropy} bits") @@ -324,13 +339,13 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): # Show channel key status if has_channel_key(): status = get_channel_status() - click.secho("─── CHANNEL KEY ───", fg='magenta') + click.secho("─── CHANNEL KEY ───", fg="magenta") click.echo(" Status: Private mode") click.echo(f" Fingerprint: {status['fingerprint']}") click.secho(f" (configured via {status['source']})", dim=True) click.echo() - click.secho("✓ v4.0.0: Use this passphrase anytime - no date needed!", fg='cyan') + click.secho("✓ v4.0.0: Use this passphrase anytime - no date needed!", fg="cyan") click.echo() except Exception as e: @@ -341,6 +356,7 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): # CHANNEL COMMAND GROUP (v4.0.0) # ============================================================================ + @cli.group() def channel(): """ @@ -373,11 +389,11 @@ def channel(): pass -@channel.command('generate') -@click.option('--save', '-s', is_flag=True, help='Save to user config (~/.stegasoo/channel.key)') -@click.option('--save-project', is_flag=True, help='Save to project config (./config/channel.key)') -@click.option('--env', '-e', is_flag=True, help='Output as environment variable export') -@click.option('--quiet', '-q', is_flag=True, help='Output only the key') +@channel.command("generate") +@click.option("--save", "-s", is_flag=True, help="Save to user config (~/.stegasoo/channel.key)") +@click.option("--save-project", is_flag=True, help="Save to project config (./config/channel.key)") +@click.option("--env", "-e", is_flag=True, help="Output as environment variable export") +@click.option("--quiet", "-q", is_flag=True, help="Output only the key") def channel_generate(save, save_project, env, quiet): """ Generate a new channel key. @@ -405,15 +421,15 @@ def channel_generate(save, save_project, env, quiet): raise click.UsageError("Cannot use both --save and --save-project") if save: - set_channel_key(key, location='user') + set_channel_key(key, location="user") if not quiet: - click.secho("✓ Channel key saved to ~/.stegasoo/channel.key", fg='green') + click.secho("✓ Channel key saved to ~/.stegasoo/channel.key", fg="green") click.echo() if save_project: - set_channel_key(key, location='project') + set_channel_key(key, location="project") if not quiet: - click.secho("✓ Channel key saved to ./config/channel.key", fg='green') + click.secho("✓ Channel key saved to ./config/channel.key", fg="green") click.echo() if env: @@ -422,9 +438,9 @@ def channel_generate(save, save_project, env, quiet): click.echo(key) else: click.echo() - click.secho("─── NEW CHANNEL KEY ───", fg='cyan', bold=True) + click.secho("─── NEW CHANNEL KEY ───", fg="cyan", bold=True) click.echo() - click.secho(f" {key}", fg='bright_yellow', bold=True) + click.secho(f" {key}", fg="bright_yellow", bold=True) click.echo() fingerprint = f"{key[:4]}-••••-••••-••••-••••-••••-••••-{key[-4:]}" @@ -443,9 +459,9 @@ def channel_generate(save, save_project, env, quiet): click.echo() -@channel.command('show') -@click.option('--reveal', '-r', is_flag=True, help='Show full key (not just fingerprint)') -@click.option('--json', 'as_json', is_flag=True, help='Output as JSON') +@channel.command("show") +@click.option("--reveal", "-r", is_flag=True, help="Show full key (not just fingerprint)") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") def channel_show(reveal, as_json): """ Display current channel key status. @@ -463,35 +479,36 @@ def channel_show(reveal, as_json): if as_json: import json + output = { - 'mode': status['mode'], - 'configured': status['configured'], - 'fingerprint': status.get('fingerprint'), - 'source': status.get('source'), + "mode": status["mode"], + "configured": status["configured"], + "fingerprint": status.get("fingerprint"), + "source": status.get("source"), } - if reveal and status['configured']: - output['key'] = status.get('key') + if reveal and status["configured"]: + output["key"] = status.get("key") click.echo(json.dumps(output, indent=2)) return click.echo() - click.secho("─── CHANNEL KEY STATUS ───", fg='cyan', bold=True) + click.secho("─── CHANNEL KEY STATUS ───", fg="cyan", bold=True) click.echo() - if status['mode'] == 'public': - click.secho(" Mode: PUBLIC", fg='yellow', bold=True) + if status["mode"] == "public": + click.secho(" Mode: PUBLIC", fg="yellow", bold=True) click.echo(" No channel key configured.") click.echo() click.secho(" Messages can be read by any Stegasoo installation", dim=True) click.secho(" with matching credentials.", dim=True) else: - click.secho(" Mode: PRIVATE", fg='green', bold=True) + click.secho(" Mode: PRIVATE", fg="green", bold=True) click.echo(f" Fingerprint: {status['fingerprint']}") click.echo(f" Source: {status['source']}") if reveal: click.echo() - click.secho(f" Full key: {status['key']}", fg='bright_yellow') + click.secho(f" Full key: {status['key']}", fg="bright_yellow") click.echo() click.secho(" Messages require this channel key to decode.", dim=True) @@ -499,10 +516,10 @@ def channel_show(reveal, as_json): click.echo() -@channel.command('set') -@click.argument('key', required=False) -@click.option('--file', '-f', 'key_file', type=click.Path(exists=True), help='Read key from file') -@click.option('--project', '-p', is_flag=True, help='Save to project config instead of user config') +@channel.command("set") +@click.argument("key", required=False) +@click.option("--file", "-f", "key_file", type=click.Path(exists=True), help="Read key from file") +@click.option("--project", "-p", is_flag=True, help="Save to project config instead of user config") def channel_set(key, key_file, project): """ Save a channel key to config file. @@ -532,19 +549,19 @@ def channel_set(key, key_file, project): "Generate a new key with: stegasoo channel generate" ) - location = 'project' if project else 'user' + location = "project" if project else "user" set_channel_key(key, location=location) status = get_channel_status() - click.secho("✓ Channel key saved", fg='green') + click.secho("✓ Channel key saved", fg="green") click.echo(f" Location: {status['source']}") click.echo(f" Fingerprint: {status['fingerprint']}") -@channel.command('clear') -@click.option('--project', '-p', is_flag=True, help='Clear project config instead of user config') -@click.option('--all', 'clear_all', is_flag=True, help='Clear both user and project configs') -@click.option('--force', '-f', is_flag=True, help='Skip confirmation') +@channel.command("clear") +@click.option("--project", "-p", is_flag=True, help="Clear project config instead of user config") +@click.option("--all", "clear_all", is_flag=True, help="Clear both user and project configs") +@click.option("--force", "-f", is_flag=True, help="Skip confirmation") def channel_clear(project, clear_all, force): """ Remove channel key from config. @@ -573,21 +590,21 @@ def channel_clear(project, clear_all, force): return if clear_all: - clear_channel_key(location='user') - clear_channel_key(location='project') - click.secho("✓ Cleared channel key from user and project configs", fg='green') + clear_channel_key(location="user") + clear_channel_key(location="project") + click.secho("✓ Cleared channel key from user and project configs", fg="green") elif project: - clear_channel_key(location='project') - click.secho("✓ Cleared channel key from project config", fg='green') + clear_channel_key(location="project") + click.secho("✓ Cleared channel key from project config", fg="green") else: - clear_channel_key(location='user') - click.secho("✓ Cleared channel key from user config", fg='green') + clear_channel_key(location="user") + click.secho("✓ Cleared channel key from user config", fg="green") # Show current status status = get_channel_status() - if status['configured']: + if status["configured"]: click.echo() - click.secho(f"Note: Channel key still active from {status['source']}", fg='yellow') + click.secho(f"Note: Channel key still active from {status['source']}", fg="yellow") click.echo(f" Fingerprint: {status['fingerprint']}") else: click.echo(" Mode is now: PUBLIC") @@ -597,31 +614,66 @@ def channel_clear(project, clear_all, force): # ENCODE COMMAND # ============================================================================ + @cli.command() -@click.option('--ref', '-r', required=True, type=click.Path(exists=True), help='Reference photo') -@click.option('--carrier', '-c', required=True, type=click.Path(exists=True), help='Carrier image') -@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('--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') -@click.option('--key-password', help='RSA key password (for encrypted .pem files)') -@click.option('--channel', 'channel_key', help='Channel key (or "auto" for server config)') -@click.option('--channel-file', type=click.Path(exists=True), help='Read channel key from file') -@click.option('--no-channel', is_flag=True, help='Force public mode (no channel key)') -@click.option('--output', '-o', type=click.Path(), help='Output file (default: auto-generated)') -@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', - help='DCT output format: png (lossless, default) or jpeg (smaller)') -@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, passphrase, pin, key, key_qr, - key_password, channel_key, channel_file, no_channel, output, embed_mode, - dct_output_format, dct_color_mode, quiet): +@click.option("--ref", "-r", required=True, type=click.Path(exists=True), help="Reference photo") +@click.option("--carrier", "-c", required=True, type=click.Path(exists=True), help="Carrier image") +@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("--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") +@click.option("--key-password", help="RSA key password (for encrypted .pem files)") +@click.option("--channel", "channel_key", help='Channel key (or "auto" for server config)') +@click.option("--channel-file", type=click.Path(exists=True), help="Read channel key from file") +@click.option("--no-channel", is_flag=True, help="Force public mode (no channel key)") +@click.option("--output", "-o", type=click.Path(), help="Output file (default: auto-generated)") +@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", + help="DCT output format: png (lossless, default) or jpeg (smaller)", +) +@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, + passphrase, + pin, + key, + key_qr, + key_password, + channel_key, + channel_file, + no_channel, + output, + embed_mode, + dct_output_format, + dct_color_mode, + quiet, +): """ Encode a secret message or file into an image. @@ -662,16 +714,18 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, passphrase, pin, stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -m "msg" --no-channel """ # Check DCT mode availability - if embed_mode == 'dct' and not has_dct_support(): - raise click.ClickException( - "DCT mode requires scipy. Install with: pip install scipy" - ) + if embed_mode == "dct" and not has_dct_support(): + raise click.ClickException("DCT mode requires scipy. Install with: pip install scipy") # Warn if DCT options used with LSB mode - if embed_mode == 'lsb': - if dct_output_format != 'png' or dct_color_mode != 'grayscale': + if embed_mode == "lsb": + if dct_output_format != "png" or dct_color_mode != "grayscale": if not quiet: - click.secho("Note: --dct-format and --dct-color only apply to DCT mode", fg='yellow', dim=True) + click.secho( + "Note: --dct-format and --dct-color only apply to DCT mode", + fg="yellow", + dim=True, + ) # Resolve channel key try: @@ -714,7 +768,7 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, passphrase, pin, key_pem = extract_key_from_qr_file(key_qr) if not key_pem: raise click.ClickException(f"Could not extract RSA key from QR code: {key_qr}") - rsa_key_data = key_pem.encode('utf-8') + rsa_key_data = key_pem.encode("utf-8") rsa_key_from_qr = True if not quiet: click.echo(f"Loaded RSA key from QR code: {key_qr}") @@ -732,13 +786,13 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, passphrase, pin, # Pre-check capacity with selected mode fit_check = will_fit_by_mode(payload, carrier_image, embed_mode=embed_mode) - if not fit_check['fits']: + if not fit_check["fits"]: # Suggest alternative mode if it would fit - alt_mode = 'lsb' if embed_mode == 'dct' else 'dct' + alt_mode = "lsb" if embed_mode == "dct" else "dct" alt_check = will_fit_by_mode(payload, carrier_image, embed_mode=alt_mode) suggestion = "" - if alt_mode == 'lsb' and alt_check['fits']: + if alt_mode == "lsb" and alt_check["fits"]: suggestion = "\n Tip: Payload would fit in LSB mode (--mode lsb)" raise click.ClickException( @@ -751,7 +805,7 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, passphrase, pin, if not quiet: mode_desc = embed_mode.upper() - if embed_mode == 'dct': + if embed_mode == "dct": mode_desc += f" ({dct_color_mode}, {dct_output_format.upper()})" click.echo(f"Mode: {mode_desc} ({fit_check['usage_percent']:.1f}% capacity)") @@ -790,12 +844,12 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, passphrase, pin, out_path.write_bytes(result.stego_image) if not quiet: - click.secho("✓ Encoded successfully!", fg='green') + click.secho("✓ 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}%") - if embed_mode == 'dct': - color_note = "color preserved" if dct_color_mode == 'color' else "grayscale" + 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) @@ -811,24 +865,49 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, passphrase, pin, # DECODE COMMAND # ============================================================================ + @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('--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') -@click.option('--key-password', help='RSA key password (for encrypted .pem files)') -@click.option('--channel', 'channel_key', help='Channel key (or "auto" for server config)') -@click.option('--channel-file', type=click.Path(exists=True), help='Read channel key from file') -@click.option('--no-channel', is_flag=True, help='Force public mode (no channel key)') -@click.option('--output', '-o', type=click.Path(), help='Save decoded content to file') -@click.option('--mode', 'embed_mode', type=click.Choice(['auto', 'lsb', 'dct']), default='auto', - 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, passphrase, pin, key, key_qr, key_password, channel_key, channel_file, - no_channel, output, embed_mode, quiet, force): +@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("--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") +@click.option("--key-password", help="RSA key password (for encrypted .pem files)") +@click.option("--channel", "channel_key", help='Channel key (or "auto" for server config)') +@click.option("--channel-file", type=click.Path(exists=True), help="Read channel key from file") +@click.option("--no-channel", is_flag=True, help="Force public mode (no channel key)") +@click.option("--output", "-o", type=click.Path(), help="Save decoded content to file") +@click.option( + "--mode", + "embed_mode", + type=click.Choice(["auto", "lsb", "dct"]), + default="auto", + 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, + passphrase, + pin, + key, + key_qr, + key_password, + channel_key, + channel_file, + no_channel, + output, + embed_mode, + quiet, + force, +): """ Decode a secret message or file from a stego image. @@ -858,10 +937,8 @@ def decode_cmd(ref, stego, passphrase, pin, key, key_qr, key_password, channel_k stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 --no-channel """ # Check DCT mode availability - if embed_mode == 'dct' and not has_dct_support(): - raise click.ClickException( - "DCT mode requires scipy. Install with: pip install scipy" - ) + if embed_mode == "dct" and not has_dct_support(): + raise click.ClickException("DCT mode requires scipy. Install with: pip install scipy") # Resolve channel key try: @@ -887,7 +964,7 @@ def decode_cmd(ref, stego, passphrase, pin, key, key_qr, key_password, channel_k key_pem = extract_key_from_qr_file(key_qr) if not key_pem: raise click.ClickException(f"Could not extract RSA key from QR code: {key_qr}") - rsa_key_data = key_pem.encode('utf-8') + rsa_key_data = key_pem.encode("utf-8") rsa_key_from_qr = True if not quiet: click.echo(f"Loaded RSA key from QR code: {key_qr}") @@ -932,7 +1009,7 @@ def decode_cmd(ref, stego, passphrase, pin, key, key_qr, key_password, channel_k out_path.write_bytes(result.file_data) if not quiet: - click.secho("✓ 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: @@ -942,20 +1019,20 @@ def decode_cmd(ref, stego, passphrase, pin, key, key_qr, key_password, channel_k if output: Path(output).write_text(result.message) if not quiet: - click.secho("✓ 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("✓ Decoded successfully!", fg='green') + click.secho("✓ Decoded successfully!", fg="green") click.echo() click.echo(result.message) except (DecryptionError, ExtractionError) as e: # Provide helpful hints for channel key mismatches error_msg = str(e) - if 'channel key' in error_msg.lower(): + if "channel key" in error_msg.lower(): raise click.ClickException(error_msg) raise click.ClickException(f"Decryption failed: {e}") except StegasooError as e: @@ -968,22 +1045,40 @@ def decode_cmd(ref, stego, passphrase, pin, key, key_qr, key_password, channel_k # VERIFY COMMAND # ============================================================================ + @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('--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') -@click.option('--key-password', help='RSA key password (for encrypted .pem files)') -@click.option('--channel', 'channel_key', help='Channel key (or "auto" for server config)') -@click.option('--channel-file', type=click.Path(exists=True), help='Read channel key from file') -@click.option('--no-channel', is_flag=True, help='Force public mode (no channel key)') -@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, passphrase, pin, key, key_qr, key_password, channel_key, channel_file, - no_channel, embed_mode, as_json): +@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("--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") +@click.option("--key-password", help="RSA key password (for encrypted .pem files)") +@click.option("--channel", "channel_key", help='Channel key (or "auto" for server config)') +@click.option("--channel-file", type=click.Path(exists=True), help="Read channel key from file") +@click.option("--no-channel", is_flag=True, help="Force public mode (no channel key)") +@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, + passphrase, + pin, + key, + key_qr, + key_password, + channel_key, + channel_file, + no_channel, + embed_mode, + as_json, +): """ Verify that a stego image can be decoded without extracting the message. @@ -1001,10 +1096,8 @@ def verify(ref, stego, passphrase, pin, key, key_qr, key_password, channel_key, stegasoo verify -r photo.jpg -s stego.png -p "test phrase" --pin 123456 --no-channel """ # Check DCT mode availability - if embed_mode == 'dct' and not has_dct_support(): - raise click.ClickException( - "DCT mode requires scipy. Install with: pip install scipy" - ) + if embed_mode == "dct" and not has_dct_support(): + raise click.ClickException("DCT mode requires scipy. Install with: pip install scipy") # Resolve channel key try: @@ -1030,7 +1123,7 @@ def verify(ref, stego, passphrase, pin, key, key_qr, key_password, channel_key, key_pem = extract_key_from_qr_file(key_qr) if not key_pem: raise click.ClickException(f"Could not extract RSA key from QR code: {key_qr}") - rsa_key_data = key_pem.encode('utf-8') + rsa_key_data = key_pem.encode("utf-8") rsa_key_from_qr = True effective_key_password = None if rsa_key_from_qr else key_password @@ -1057,22 +1150,23 @@ def verify(ref, stego, passphrase, pin, key, key_qr, key_password, channel_key, # Calculate payload size if result.is_file: payload_size = len(result.file_data) - content_type = result.mime_type or 'file' + content_type = result.mime_type or "file" else: - payload_size = len(result.message.encode('utf-8')) - content_type = 'text' + payload_size = len(result.message.encode("utf-8")) + content_type = "text" if as_json: import json + output = { - 'valid': True, - 'content_type': content_type, - 'payload_size': payload_size, - 'filename': result.filename if result.is_file else None, + "valid": True, + "content_type": content_type, + "payload_size": payload_size, + "filename": result.filename if result.is_file else None, } click.echo(json.dumps(output, indent=2)) else: - click.secho("✓ Verification successful!", fg='green') + click.secho("✓ Verification successful!", fg="green") click.echo(f" Content type: {content_type}") click.echo(f" Payload size: {payload_size:,} bytes") if result.is_file and result.filename: @@ -1081,9 +1175,10 @@ def verify(ref, stego, passphrase, pin, key, key_qr, key_password, channel_key, except (DecryptionError, ExtractionError) as e: if as_json: import json + output = { - 'valid': False, - 'error': str(e), + "valid": False, + "error": str(e), } click.echo(json.dumps(output, indent=2)) sys.exit(1) @@ -1099,9 +1194,10 @@ def verify(ref, stego, passphrase, pin, key, key_qr, key_password, channel_key, # INFO COMMAND # ============================================================================ + @cli.command() -@click.argument('image', type=click.Path(exists=True)) -@click.option('--json', 'as_json', is_flag=True, help='Output as JSON') +@click.argument("image", type=click.Path(exists=True)) +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") def info(image, as_json): """ Show information about an image file. @@ -1120,21 +1216,22 @@ def info(image, as_json): if as_json: import json + click.echo(json.dumps(img_info, indent=2)) return click.echo() - click.secho(f"=== Image Info: {image} ===", fg='cyan', bold=True) + click.secho(f"=== Image Info: {image} ===", fg="cyan", bold=True) click.echo(f" Format: {img_info.get('format', 'Unknown')}") click.echo(f" Dimensions: {img_info.get('width', '?')} × {img_info.get('height', '?')}") click.echo(f" Mode: {img_info.get('mode', '?')}") click.echo(f" Size: {len(image_data):,} bytes") - if 'lsb_capacity' in img_info: + if "lsb_capacity" in img_info: click.echo() - click.secho(" Capacity Estimates:", fg='green') + click.secho(" Capacity Estimates:", fg="green") click.echo(f" LSB mode: {img_info['lsb_capacity']:,} bytes") - if 'dct_capacity' in img_info: + if "dct_capacity" in img_info: click.echo(f" DCT mode: {img_info['dct_capacity']:,} bytes") click.echo() @@ -1147,11 +1244,12 @@ def info(image, as_json): # COMPARE COMMAND # ============================================================================ + @cli.command() -@click.argument('image', type=click.Path(exists=True)) -@click.option('--payload', '-p', type=click.Path(exists=True), help='Check if this file would fit') -@click.option('--size', '-s', type=int, help='Check if this many bytes would fit') -@click.option('--json', 'as_json', is_flag=True, help='Output as JSON') +@click.argument("image", type=click.Path(exists=True)) +@click.option("--payload", "-p", type=click.Path(exists=True), help="Check if this file would fit") +@click.option("--size", "-s", type=int, help="Check if this many bytes would fit") +@click.option("--json", "as_json", is_flag=True, help="Output as JSON") def compare(image, payload, size, as_json): """ Compare embedding mode capacities for an image. @@ -1179,24 +1277,25 @@ def compare(image, payload, size, as_json): if as_json: import json + output_data = { "file": image, - "width": comparison['width'], - "height": comparison['height'], + "width": comparison["width"], + "height": comparison["height"], "modes": { "lsb": { - "capacity_bytes": comparison['lsb']['capacity_bytes'], - "capacity_kb": round(comparison['lsb']['capacity_kb'], 1), + "capacity_bytes": comparison["lsb"]["capacity_bytes"], + "capacity_kb": round(comparison["lsb"]["capacity_kb"], 1), "available": True, - "output_format": comparison['lsb']['output'], + "output_format": comparison["lsb"]["output"], }, "dct": { - "capacity_bytes": comparison['dct']['capacity_bytes'], - "capacity_kb": round(comparison['dct']['capacity_kb'], 1), - "available": comparison['dct']['available'], + "capacity_bytes": comparison["dct"]["capacity_bytes"], + "capacity_kb": round(comparison["dct"]["capacity_kb"], 1), + "available": comparison["dct"]["available"], "output_formats": ["png", "jpeg"], "color_modes": ["grayscale", "color"], - "ratio_vs_lsb_percent": round(comparison['dct']['ratio_vs_lsb'], 1), + "ratio_vs_lsb_percent": round(comparison["dct"]["ratio_vs_lsb"], 1), }, }, } @@ -1204,49 +1303,53 @@ def compare(image, payload, size, as_json): if payload_size: output_data["payload_check"] = { "size_bytes": payload_size, - "fits_lsb": payload_size <= comparison['lsb']['capacity_bytes'], - "fits_dct": payload_size <= comparison['dct']['capacity_bytes'], + "fits_lsb": payload_size <= comparison["lsb"]["capacity_bytes"], + "fits_dct": payload_size <= comparison["dct"]["capacity_bytes"], } click.echo(json.dumps(output_data, indent=2)) return click.echo() - click.secho(f"=== Mode Comparison: {image} ===", fg='cyan', bold=True) + click.secho(f"=== Mode Comparison: {image} ===", fg="cyan", bold=True) click.echo(f" Dimensions: {comparison['width']} × {comparison['height']}") click.echo() # LSB mode - click.secho(" ┌─── LSB Mode ───", fg='green') - click.echo(f" │ Capacity: {comparison['lsb']['capacity_bytes']:,} bytes ({comparison['lsb']['capacity_kb']:.1f} KB)") + 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(" │ Status: ✓ Available") click.echo(" │") # DCT mode - click.secho(" ├─── DCT Mode ───", fg='blue') - click.echo(f" │ Capacity: {comparison['dct']['capacity_bytes']:,} bytes ({comparison['dct']['capacity_kb']:.1f} KB)") + click.secho(" ├─── DCT Mode ───", fg="blue") + 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']: + if comparison["dct"]["available"]: click.echo(" │ Status: ✓ Available") click.echo(" │ Formats: PNG (lossless), JPEG (smaller)") click.echo(" │ Colors: Grayscale (default), Color") else: - click.secho(" │ Status: ✗ Requires scipy (pip install scipy)", fg='yellow') + click.secho(" │ Status: ✗ Requires scipy (pip install scipy)", fg="yellow") click.echo(" │") # Payload check if payload_size: - click.secho(" ├─── Payload Check ───", fg='magenta') + click.secho(" ├─── Payload Check ───", fg="magenta") click.echo(f" │ Size: {payload_size:,} bytes") - fits_lsb = payload_size <= comparison['lsb']['capacity_bytes'] - fits_dct = payload_size <= comparison['dct']['capacity_bytes'] + fits_lsb = payload_size <= comparison["lsb"]["capacity_bytes"] + fits_dct = payload_size <= comparison["dct"]["capacity_bytes"] 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' + lsb_color = "green" if fits_lsb else "red" + dct_color = "green" if fits_dct else "red" click.echo(" │ LSB mode: ", nl=False) click.secho(f"{lsb_icon} {'Fits' if fits_lsb else 'Too large'}", fg=lsb_color) @@ -1255,8 +1358,8 @@ def compare(image, payload, size, as_json): click.echo(" │") # Recommendation - click.secho(" └─── Recommendation ───", fg='yellow') - if not comparison['dct']['available']: + click.secho(" └─── Recommendation ───", fg="yellow") + if not comparison["dct"]["available"]: click.echo(" Use LSB mode (DCT unavailable)") elif payload_size: if fits_dct: @@ -1265,7 +1368,7 @@ def compare(image, payload, size, as_json): elif fits_lsb: click.echo(" LSB mode (payload too large for DCT)") else: - click.secho(" ✗ 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") @@ -1280,12 +1383,19 @@ def compare(image, payload, size, as_json): # STRIP-METADATA COMMAND # ============================================================================ -@cli.command('strip-metadata') -@click.argument('image', type=click.Path(exists=True)) -@click.option('--output', '-o', type=click.Path(), help='Output file (default: overwrites input)') -@click.option('--format', '-f', 'output_format', type=click.Choice(['PNG', 'BMP']), default='PNG', - help='Output format') -@click.option('--quiet', '-q', is_flag=True, help='Suppress output') + +@cli.command("strip-metadata") +@click.argument("image", type=click.Path(exists=True)) +@click.option("--output", "-o", type=click.Path(), help="Output file (default: overwrites input)") +@click.option( + "--format", + "-f", + "output_format", + type=click.Choice(["PNG", "BMP"]), + default="PNG", + help="Output format", +) +@click.option("--quiet", "-q", is_flag=True, help="Suppress output") def strip_metadata_cmd(image, output, output_format, quiet): """ Remove all metadata (EXIF, GPS, etc.) from an image. @@ -1311,12 +1421,12 @@ def strip_metadata_cmd(image, output, output_format, quiet): out_path = Path(output) else: # Replace extension with output format - out_path = Path(image).with_suffix(f'.{output_format.lower()}') + out_path = Path(image).with_suffix(f".{output_format.lower()}") out_path.write_bytes(clean_data) if not quiet: - click.secho("✓ 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)") @@ -1328,6 +1438,7 @@ def strip_metadata_cmd(image, output, output_format, quiet): # MODES COMMAND # ============================================================================ + @cli.command() def modes(): """ @@ -1336,11 +1447,11 @@ def modes(): Displays which modes are available and their characteristics. """ click.echo() - click.secho("=== Stegasoo Embedding Modes (v4.0.0) ===", fg='cyan', bold=True) + click.secho("=== Stegasoo Embedding Modes (v4.0.0) ===", fg="cyan", bold=True) click.echo() # LSB Mode - click.secho(" LSB Mode (Spatial LSB)", fg='green', bold=True) + click.secho(" LSB Mode (Spatial LSB)", fg="green", bold=True) click.echo(" Status: ✓ Always available") click.echo(" Output: PNG/BMP (full color)") click.echo(" Capacity: ~375 KB per megapixel") @@ -1349,11 +1460,11 @@ def modes(): click.echo() # DCT Mode - click.secho(" DCT Mode (Frequency Domain)", fg='blue', bold=True) + click.secho(" DCT Mode (Frequency Domain)", fg="blue", bold=True) if has_dct_support(): click.echo(" Status: ✓ Available") else: - click.secho(" Status: ✗ 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") @@ -1361,7 +1472,7 @@ def modes(): click.echo() # DCT Options - click.secho(" DCT Options", fg='magenta', bold=True) + click.secho(" DCT Options", fg="magenta", bold=True) click.echo(" Output format:") click.echo(" --dct-format png Lossless, larger file (default)") click.echo(" --dct-format jpeg Lossy, smaller, more natural") @@ -1372,9 +1483,9 @@ def modes(): click.echo() # Channel Key Status (v4.0.0) - click.secho(" Channel Key (v4.0.0)", fg='cyan', bold=True) + click.secho(" Channel Key (v4.0.0)", fg="cyan", bold=True) status = get_channel_status() - if status['mode'] == 'public': + if status["mode"] == "public": click.echo(" Status: PUBLIC (no key configured)") click.echo(" Effect: Messages readable by any installation") else: @@ -1390,7 +1501,7 @@ def modes(): click.echo() # v4.0.0 Changes - click.secho(" v4.0.0 Changes:", fg='cyan', bold=True) + click.secho(" v4.0.0 Changes:", fg="cyan", bold=True) click.echo(" ✓ Channel key support for deployment isolation") click.echo(" ✓ New `stegasoo channel` command group") click.echo(" ✓ Messages encoded with channel key require same key to decode") @@ -1413,10 +1524,11 @@ def modes(): # MAIN # ============================================================================ + def main(): """Entry point.""" cli() -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/frontends/web/app.py b/frontends/web/app.py index d47c6bd..3728102 100644 --- a/frontends/web/app.py +++ b/frontends/web/app.py @@ -32,11 +32,11 @@ from pathlib import Path from flask import Flask, flash, jsonify, redirect, render_template, request, send_file, url_for from PIL import Image -os.environ['NUMPY_MADVISE_HUGEPAGE'] = '0' -os.environ['OMP_NUM_THREADS'] = '1' +os.environ["NUMPY_MADVISE_HUGEPAGE"] = "0" +os.environ["OMP_NUM_THREADS"] = "1" # Add parent to path for development -sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src')) +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) import stegasoo from stegasoo import ( @@ -83,6 +83,7 @@ from stegasoo.constants import ( try: import qrcode # noqa: F401 from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_M # noqa: F401 + HAS_QRCODE = True except ImportError: HAS_QRCODE = False @@ -90,6 +91,7 @@ except ImportError: # QR Code reading try: from pyzbar.pyzbar import decode as pyzbar_decode # noqa: F401 + HAS_QRCODE_READ = True except ImportError: HAS_QRCODE_READ = False @@ -120,7 +122,7 @@ subprocess_stego = SubprocessStego(timeout=180) # 3 minute timeout for large im app = Flask(__name__) app.secret_key = secrets.token_hex(32) -app.config['MAX_CONTENT_LENGTH'] = MAX_FILE_SIZE +app.config["MAX_CONTENT_LENGTH"] = MAX_FILE_SIZE # Temporary file storage for sharing (file_id -> {data, timestamp, filename}) TEMP_FILES: dict[str, dict] = {} @@ -131,6 +133,7 @@ THUMBNAIL_FILES: dict[str, bytes] = {} # TEMPLATE CONTEXT PROCESSOR # ============================================================================ + @app.context_processor def inject_globals(): """Inject global variables into all templates.""" @@ -138,24 +141,24 @@ def inject_globals(): channel_status = get_channel_status() return { - 'version': __version__, - 'max_message_chars': MAX_MESSAGE_CHARS, - 'max_payload_kb': MAX_FILE_PAYLOAD_SIZE // 1024, - 'max_upload_mb': MAX_UPLOAD_SIZE // (1024 * 1024), - 'temp_file_expiry_minutes': TEMP_FILE_EXPIRY_MINUTES, - 'min_pin_length': MIN_PIN_LENGTH, - 'max_pin_length': MAX_PIN_LENGTH, + "version": __version__, + "max_message_chars": MAX_MESSAGE_CHARS, + "max_payload_kb": MAX_FILE_PAYLOAD_SIZE // 1024, + "max_upload_mb": MAX_UPLOAD_SIZE // (1024 * 1024), + "temp_file_expiry_minutes": TEMP_FILE_EXPIRY_MINUTES, + "min_pin_length": MIN_PIN_LENGTH, + "max_pin_length": MAX_PIN_LENGTH, # NEW in v3.2.0 - 'min_passphrase_words': MIN_PASSPHRASE_WORDS, - 'recommended_passphrase_words': RECOMMENDED_PASSPHRASE_WORDS, - 'default_passphrase_words': DEFAULT_PASSPHRASE_WORDS, + "min_passphrase_words": MIN_PASSPHRASE_WORDS, + "recommended_passphrase_words": RECOMMENDED_PASSPHRASE_WORDS, + "default_passphrase_words": DEFAULT_PASSPHRASE_WORDS, # NEW in v3.0 - 'has_dct': has_dct_support(), + "has_dct": has_dct_support(), # NEW in v4.0.0 - Channel key status - 'channel_mode': channel_status['mode'], - 'channel_configured': channel_status['configured'], - 'channel_fingerprint': channel_status.get('fingerprint'), - 'channel_source': channel_status.get('source'), + "channel_mode": channel_status["mode"], + "channel_configured": channel_status["configured"], + "channel_fingerprint": channel_status.get("fingerprint"), + "channel_source": channel_status.get("source"), } @@ -173,13 +176,13 @@ try: # Channel key status (v4.0.0) channel_status = get_channel_status() print(f"Channel key: {channel_status['mode']} mode") - if channel_status['configured']: + if channel_status["configured"]: print(f" Fingerprint: {channel_status.get('fingerprint')}") print(f" Source: {channel_status.get('source')}") DESIRED_PAYLOAD_SIZE = 2 * 1024 * 1024 # 2MB - if hasattr(stegasoo, 'MAX_FILE_PAYLOAD_SIZE'): + if hasattr(stegasoo, "MAX_FILE_PAYLOAD_SIZE"): print(f"Overriding MAX_FILE_PAYLOAD_SIZE to {DESIRED_PAYLOAD_SIZE}") stegasoo.MAX_FILE_PAYLOAD_SIZE = DESIRED_PAYLOAD_SIZE @@ -191,6 +194,7 @@ except Exception as e: # CHANNEL KEY HELPER (v4.0.0) # ============================================================================ + def resolve_channel_key_form(channel_key_value: str) -> str: """ Resolve channel key from form input. @@ -201,17 +205,17 @@ def resolve_channel_key_form(channel_key_value: str) -> str: Returns: Value to pass to subprocess_stego ('auto', 'none', or explicit key) """ - if not channel_key_value or channel_key_value == 'auto': - return 'auto' - elif channel_key_value == 'none': - return 'none' + if not channel_key_value or channel_key_value == "auto": + return "auto" + elif channel_key_value == "none": + return "none" else: # Explicit key - validate format if validate_channel_key(channel_key_value): return channel_key_value else: # Invalid format, fall back to auto - return 'auto' + return "auto" def generate_thumbnail(image_data: bytes, size: tuple = THUMBNAIL_SIZE) -> bytes: @@ -219,25 +223,25 @@ def generate_thumbnail(image_data: bytes, size: tuple = THUMBNAIL_SIZE) -> bytes try: with Image.open(io.BytesIO(image_data)) as img: # Convert to RGB if necessary (handle grayscale too) - if img.mode in ('RGBA', 'LA', 'P'): + if img.mode in ("RGBA", "LA", "P"): # Create white background for transparent images - background = Image.new('RGB', img.size, (255, 255, 255)) - if img.mode == 'P': - img = img.convert('RGBA') - background.paste(img, mask=img.split()[-1] if img.mode == 'RGBA' else None) + background = Image.new("RGB", img.size, (255, 255, 255)) + if img.mode == "P": + img = img.convert("RGBA") + background.paste(img, mask=img.split()[-1] if img.mode == "RGBA" else None) img = background - elif img.mode == 'L': + elif img.mode == "L": # Convert grayscale to RGB for thumbnail - img = img.convert('RGB') - elif img.mode != 'RGB': - img = img.convert('RGB') + img = img.convert("RGB") + elif img.mode != "RGB": + img = img.convert("RGB") # Create thumbnail img.thumbnail(size, Image.Resampling.LANCZOS) # Save to bytes buffer = io.BytesIO() - img.save(buffer, format='JPEG', quality=THUMBNAIL_QUALITY, optimize=True) + img.save(buffer, format="JPEG", quality=THUMBNAIL_QUALITY, optimize=True) return buffer.getvalue() except Exception as e: print(f"Thumbnail generation error: {e}") @@ -247,7 +251,9 @@ def generate_thumbnail(image_data: bytes, size: tuple = THUMBNAIL_SIZE) -> bytes def cleanup_temp_files(): """Remove expired temporary files.""" now = time.time() - expired = [fid for fid, info in TEMP_FILES.items() if now - info['timestamp'] > TEMP_FILE_EXPIRY] + expired = [ + fid for fid, info in TEMP_FILES.items() if now - info["timestamp"] > TEMP_FILE_EXPIRY + ] for fid in expired: TEMP_FILES.pop(fid, None) @@ -258,10 +264,10 @@ def cleanup_temp_files(): def allowed_image(filename: str) -> bool: """Check if file has allowed image extension.""" - if not filename or '.' not in filename: + if not filename or "." not in filename: return False - ext = filename.rsplit('.', 1)[1].lower() - return ext in {'png', 'jpg', 'jpeg', 'bmp', 'gif'} + ext = filename.rsplit(".", 1)[1].lower() + return ext in {"png", "jpg", "jpeg", "bmp", "gif"} def format_size(size_bytes: int) -> str: @@ -278,16 +284,18 @@ def format_size(size_bytes: int) -> str: # ROUTES # ============================================================================ -@app.route('/') + +@app.route("/") def index(): - return render_template('index.html') + return render_template("index.html") # ============================================================================ # CHANNEL KEY API (v4.0.0) # ============================================================================ -@app.route('/api/channel/status') + +@app.route("/api/channel/status") def api_channel_status(): """ Get current channel key status (v4.0.0). @@ -298,70 +306,81 @@ def api_channel_status(): result = subprocess_stego.get_channel_status(reveal=False) if result.success: - return jsonify({ - 'success': True, - 'mode': result.mode, - 'configured': result.configured, - 'fingerprint': result.fingerprint, - 'source': result.source, - }) + return jsonify( + { + "success": True, + "mode": result.mode, + "configured": result.configured, + "fingerprint": result.fingerprint, + "source": result.source, + } + ) else: # Fallback to direct call if subprocess fails status = get_channel_status() - return jsonify({ - 'success': True, - 'mode': status['mode'], - 'configured': status['configured'], - 'fingerprint': status.get('fingerprint'), - 'source': status.get('source'), - }) + return jsonify( + { + "success": True, + "mode": status["mode"], + "configured": status["configured"], + "fingerprint": status.get("fingerprint"), + "source": status.get("source"), + } + ) -@app.route('/api/channel/validate', methods=['POST']) +@app.route("/api/channel/validate", methods=["POST"]) def api_channel_validate(): """ Validate a channel key format (v4.0.0). Returns JSON with validation result. """ - key = request.form.get('key', '') or request.json.get('key', '') if request.is_json else '' + key = request.form.get("key", "") or request.json.get("key", "") if request.is_json else "" if not key: - return jsonify({'valid': False, 'error': 'No key provided'}) + return jsonify({"valid": False, "error": "No key provided"}) is_valid = validate_channel_key(key) if is_valid: fingerprint = f"{key[:4]}-••••-••••-••••-••••-••••-••••-{key[-4:]}" - return jsonify({ - 'valid': True, - 'fingerprint': fingerprint, - }) + return jsonify( + { + "valid": True, + "fingerprint": fingerprint, + } + ) else: - return jsonify({ - 'valid': False, - 'error': 'Invalid format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX', - }) + return jsonify( + { + "valid": False, + "error": "Invalid format. Expected: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX", + } + ) # ============================================================================ # GENERATE # ============================================================================ -@app.route('/generate', methods=['GET', 'POST']) + +@app.route("/generate", methods=["GET", "POST"]) def generate(): - if request.method == 'POST': + if request.method == "POST": # v3.2.0: Changed from words_per_phrase to words_per_passphrase, default increased to 4 - words_per_passphrase = int(request.form.get('words_per_passphrase', DEFAULT_PASSPHRASE_WORDS)) - use_pin = request.form.get('use_pin') == 'on' - use_rsa = request.form.get('use_rsa') == 'on' + words_per_passphrase = int( + request.form.get("words_per_passphrase", DEFAULT_PASSPHRASE_WORDS) + ) + use_pin = request.form.get("use_pin") == "on" + use_rsa = request.form.get("use_rsa") == "on" if not use_pin and not use_rsa: - flash('You must select at least one security factor (PIN or RSA Key)', 'error') - return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) + flash("You must select at least one security factor (PIN or RSA Key)", "error") + return render_template("generate.html", generated=False, has_qrcode=HAS_QRCODE) - pin_length = int(request.form.get('pin_length', 6)) - rsa_bits = int(request.form.get('rsa_bits', 2048)) + pin_length = int(request.form.get("pin_length", 6)) + rsa_bits = int(request.form.get("rsa_bits", 2048)) # Clamp values words_per_passphrase = max(MIN_PASSPHRASE_WORDS, min(12, words_per_passphrase)) @@ -395,15 +414,16 @@ def generate(): qr_token = secrets.token_urlsafe(16) cleanup_temp_files() TEMP_FILES[qr_token] = { - 'data': creds.rsa_key_pem.encode(), - 'filename': 'rsa_key.pem', - 'timestamp': time.time(), - 'type': 'rsa_key', - 'compress': qr_needs_compression + "data": creds.rsa_key_pem.encode(), + "filename": "rsa_key.pem", + "timestamp": time.time(), + "type": "rsa_key", + "compress": qr_needs_compression, } # v3.2.0: Single passphrase instead of daily phrases - return render_template('generate.html', + return render_template( + "generate.html", passphrase=creds.passphrase, # v3.2.0: Single passphrase pin=creds.pin, generated=True, @@ -420,16 +440,16 @@ def generate(): has_qrcode=HAS_QRCODE, qr_token=qr_token, qr_needs_compression=qr_needs_compression, - qr_too_large=qr_too_large + qr_too_large=qr_too_large, ) except Exception as e: - flash(f'Error generating credentials: {e}', 'error') - return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) + flash(f"Error generating credentials: {e}", "error") + return render_template("generate.html", generated=False, has_qrcode=HAS_QRCODE) - return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) + return render_template("generate.html", generated=False, has_qrcode=HAS_QRCODE) -@app.route('/generate/qr/') +@app.route("/generate/qr/") def generate_qr(token): """Generate QR code for RSA key.""" if not HAS_QRCODE: @@ -439,24 +459,20 @@ def generate_qr(token): return "Token expired or invalid", 404 file_info = TEMP_FILES[token] - if file_info.get('type') != 'rsa_key': + if file_info.get("type") != "rsa_key": return "Invalid token type", 400 try: - key_pem = file_info['data'].decode('utf-8') - compress = file_info.get('compress', False) + key_pem = file_info["data"].decode("utf-8") + compress = file_info.get("compress", False) qr_png = generate_qr_code(key_pem, compress=compress) - return send_file( - io.BytesIO(qr_png), - mimetype='image/png', - as_attachment=False - ) + return send_file(io.BytesIO(qr_png), mimetype="image/png", as_attachment=False) except Exception as e: return f"Error generating QR code: {e}", 500 -@app.route('/generate/qr-download/') +@app.route("/generate/qr-download/") def generate_qr_download(token): """Download QR code as PNG file.""" if not HAS_QRCODE: @@ -466,25 +482,25 @@ def generate_qr_download(token): return "Token expired or invalid", 404 file_info = TEMP_FILES[token] - if file_info.get('type') != 'rsa_key': + if file_info.get("type") != "rsa_key": return "Invalid token type", 400 try: - key_pem = file_info['data'].decode('utf-8') - compress = file_info.get('compress', False) + key_pem = file_info["data"].decode("utf-8") + compress = file_info.get("compress", False) qr_png = generate_qr_code(key_pem, compress=compress) return send_file( io.BytesIO(qr_png), - mimetype='image/png', + mimetype="image/png", as_attachment=True, - download_name='stegasoo_rsa_key_qr.png' + download_name="stegasoo_rsa_key_qr.png", ) except Exception as e: return f"Error generating QR code: {e}", 500 -@app.route('/qr/crop', methods=['POST']) +@app.route("/qr/crop", methods=["POST"]) def qr_crop(): """ Detect and crop QR code from an image. @@ -493,11 +509,11 @@ def qr_crop(): with extra background, etc. Returns the cropped QR as PNG. """ if not HAS_QRCODE_READ: - return jsonify({'error': 'QR code reading not available (install pyzbar)'}), 501 + return jsonify({"error": "QR code reading not available (install pyzbar)"}), 501 - image_file = request.files.get('image') + image_file = request.files.get("image") if not image_file: - return jsonify({'error': 'No image provided'}), 400 + return jsonify({"error": "No image provided"}), 400 try: image_data = image_file.read() @@ -506,108 +522,102 @@ def qr_crop(): cropped = detect_and_crop_qr(image_data) if cropped is None: - return jsonify({'error': 'No QR code detected in image'}), 404 + return jsonify({"error": "No QR code detected in image"}), 404 # Return as downloadable PNG or inline based on query param - as_attachment = request.args.get('download', '').lower() in ('1', 'true', 'yes') + as_attachment = request.args.get("download", "").lower() in ("1", "true", "yes") return send_file( io.BytesIO(cropped), - mimetype='image/png', + mimetype="image/png", as_attachment=as_attachment, - download_name='cropped_qr.png' + download_name="cropped_qr.png", ) except Exception as e: - return jsonify({'error': f'Error processing image: {e}'}), 500 + return jsonify({"error": f"Error processing image: {e}"}), 500 -@app.route('/generate/download-key', methods=['POST']) +@app.route("/generate/download-key", methods=["POST"]) def download_key(): """Download RSA key as password-protected PEM file.""" - key_pem = request.form.get('key_pem', '') - password = request.form.get('key_password', '') + key_pem = request.form.get("key_pem", "") + password = request.form.get("key_password", "") if not key_pem: - flash('No key to download', 'error') - return redirect(url_for('generate')) + flash("No key to download", "error") + return redirect(url_for("generate")) if not password or len(password) < 8: - flash('Password must be at least 8 characters', 'error') - return redirect(url_for('generate')) + flash("Password must be at least 8 characters", "error") + return redirect(url_for("generate")) try: - private_key = load_rsa_key(key_pem.encode('utf-8')) + private_key = load_rsa_key(key_pem.encode("utf-8")) encrypted_pem = export_rsa_key_pem(private_key, password=password) key_id = secrets.token_hex(4) - filename = f'stegasoo_key_{private_key.key_size}_{key_id}.pem' + filename = f"stegasoo_key_{private_key.key_size}_{key_id}.pem" return send_file( io.BytesIO(encrypted_pem), - mimetype='application/x-pem-file', + mimetype="application/x-pem-file", as_attachment=True, - download_name=filename + download_name=filename, ) except Exception as e: - flash(f'Error creating key file: {e}', 'error') - return redirect(url_for('generate')) + flash(f"Error creating key file: {e}", "error") + return redirect(url_for("generate")) -@app.route('/extract-key-from-qr', methods=['POST']) +@app.route("/extract-key-from-qr", methods=["POST"]) def extract_key_from_qr_route(): """ Extract RSA key from uploaded QR code image. Returns JSON with the extracted key or error. """ if not HAS_QRCODE_READ: - return jsonify({ - 'success': False, - 'error': 'QR code reading not available. Install pyzbar and libzbar.' - }), 501 + return ( + jsonify( + { + "success": False, + "error": "QR code reading not available. Install pyzbar and libzbar.", + } + ), + 501, + ) - qr_image = request.files.get('qr_image') + qr_image = request.files.get("qr_image") if not qr_image: - return jsonify({ - 'success': False, - 'error': 'No QR image provided' - }), 400 + return jsonify({"success": False, "error": "No QR image provided"}), 400 try: image_data = qr_image.read() key_pem = extract_key_from_qr(image_data) if key_pem: - return jsonify({ - 'success': True, - 'key_pem': key_pem - }) + return jsonify({"success": True, "key_pem": key_pem}) else: - return jsonify({ - 'success': False, - 'error': 'No valid RSA key found in QR code' - }), 400 + return jsonify({"success": False, "error": "No valid RSA key found in QR code"}), 400 except Exception as e: - return jsonify({ - 'success': False, - 'error': str(e) - }), 500 + return jsonify({"success": False, "error": str(e)}), 500 # ============================================================================ # NEW in v3.0 - CAPACITY COMPARISON API # ============================================================================ -@app.route('/api/compare-capacity', methods=['POST']) + +@app.route("/api/compare-capacity", methods=["POST"]) def api_compare_capacity(): """ Compare LSB and DCT capacity for an uploaded carrier image. Returns JSON with capacity info for both modes. Uses subprocess isolation to prevent crashes. """ - carrier = request.files.get('carrier') + carrier = request.files.get("carrier") if not carrier: - return jsonify({'error': 'No carrier image provided'}), 400 + return jsonify({"error": "No carrier image provided"}), 400 try: carrier_data = carrier.read() @@ -616,48 +626,50 @@ def api_compare_capacity(): result = subprocess_stego.compare_modes(carrier_data) if not result.success: - return jsonify({'error': result.error or 'Comparison failed'}), 500 + return jsonify({"error": result.error or "Comparison failed"}), 500 - return jsonify({ - 'success': True, - 'width': result.width, - 'height': result.height, - 'lsb': { - 'capacity_bytes': result.lsb['capacity_bytes'], - 'capacity_kb': round(result.lsb['capacity_kb'], 1), - 'output': result.lsb.get('output', 'PNG'), - }, - 'dct': { - 'capacity_bytes': result.dct['capacity_bytes'], - 'capacity_kb': round(result.dct['capacity_kb'], 1), - 'output': result.dct.get('output', 'JPEG'), - 'available': result.dct.get('available', True), - 'ratio': round(result.dct.get('ratio_vs_lsb', 0), 1), + return jsonify( + { + "success": True, + "width": result.width, + "height": result.height, + "lsb": { + "capacity_bytes": result.lsb["capacity_bytes"], + "capacity_kb": round(result.lsb["capacity_kb"], 1), + "output": result.lsb.get("output", "PNG"), + }, + "dct": { + "capacity_bytes": result.dct["capacity_bytes"], + "capacity_kb": round(result.dct["capacity_kb"], 1), + "output": result.dct.get("output", "JPEG"), + "available": result.dct.get("available", True), + "ratio": round(result.dct.get("ratio_vs_lsb", 0), 1), + }, } - }) + ) except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 -@app.route('/api/check-fit', methods=['POST']) +@app.route("/api/check-fit", methods=["POST"]) def api_check_fit(): """ Check if a payload will fit in the carrier with selected mode. Returns JSON with fit status and details. Uses subprocess isolation to prevent crashes. """ - carrier = request.files.get('carrier') - payload_size = request.form.get('payload_size', type=int) - embed_mode = request.form.get('embed_mode', 'lsb') + carrier = request.files.get("carrier") + payload_size = request.form.get("payload_size", type=int) + embed_mode = request.form.get("embed_mode", "lsb") if not carrier or payload_size is None: - return jsonify({'error': 'Missing carrier or payload_size'}), 400 + return jsonify({"error": "Missing carrier or payload_size"}), 400 - if embed_mode not in ('lsb', 'dct'): - return jsonify({'error': 'Invalid embed_mode'}), 400 + if embed_mode not in ("lsb", "dct"): + return jsonify({"error": "Invalid embed_mode"}), 400 - if embed_mode == 'dct' and not has_dct_support(): - return jsonify({'error': 'DCT mode requires scipy'}), 400 + if embed_mode == "dct" and not has_dct_support(): + return jsonify({"error": "DCT mode requires scipy"}), 400 try: carrier_data = carrier.read() @@ -670,111 +682,112 @@ def api_check_fit(): ) if not result.success: - return jsonify({'error': result.error or 'Capacity check failed'}), 500 + return jsonify({"error": result.error or "Capacity check failed"}), 500 - return jsonify({ - 'success': True, - 'fits': result.fits, - 'payload_size': result.payload_size, - 'capacity': result.capacity, - 'usage_percent': round(result.usage_percent, 1), - 'headroom': result.headroom, - 'mode': result.mode, - }) + return jsonify( + { + "success": True, + "fits": result.fits, + "payload_size": result.payload_size, + "capacity": result.capacity, + "usage_percent": round(result.usage_percent, 1), + "headroom": result.headroom, + "mode": result.mode, + } + ) except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 # ============================================================================ # ENCODE # ============================================================================ -@app.route('/encode', methods=['GET', 'POST']) + +@app.route("/encode", methods=["GET", "POST"]) def encode_page(): - if request.method == 'POST': + if request.method == "POST": try: # Get files - ref_photo = request.files.get('reference_photo') - carrier = request.files.get('carrier') - rsa_key_file = request.files.get('rsa_key') - payload_file = request.files.get('payload_file') + ref_photo = request.files.get("reference_photo") + carrier = request.files.get("carrier") + rsa_key_file = request.files.get("rsa_key") + payload_file = request.files.get("payload_file") if not ref_photo or not carrier: - flash('Both reference photo and carrier image are required', 'error') - return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) + flash("Both reference photo and carrier image are required", "error") + return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ) if not allowed_image(ref_photo.filename) or not allowed_image(carrier.filename): - flash('Invalid file type. Use PNG, JPG, or BMP', 'error') - return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) + flash("Invalid file type. Use PNG, JPG, or BMP", "error") + return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ) # Get form data - v3.2.0: renamed from day_phrase to passphrase - message = request.form.get('message', '') - passphrase = request.form.get('passphrase', '') # v3.2.0: Renamed - pin = request.form.get('pin', '').strip() - rsa_password = request.form.get('rsa_password', '') - payload_type = request.form.get('payload_type', 'text') + message = request.form.get("message", "") + passphrase = request.form.get("passphrase", "") # v3.2.0: Renamed + pin = request.form.get("pin", "").strip() + rsa_password = request.form.get("rsa_password", "") + payload_type = request.form.get("payload_type", "text") # NEW in v3.0 - Embedding mode - embed_mode = request.form.get('embed_mode', 'lsb') - if embed_mode not in ('lsb', 'dct'): - embed_mode = 'lsb' + embed_mode = request.form.get("embed_mode", "lsb") + if embed_mode not in ("lsb", "dct"): + embed_mode = "lsb" # NEW in v3.0.1 - DCT output format - dct_output_format = request.form.get('dct_output_format', 'png') - if dct_output_format not in ('png', 'jpeg'): - dct_output_format = 'png' + dct_output_format = request.form.get("dct_output_format", "png") + if dct_output_format not in ("png", "jpeg"): + dct_output_format = "png" # NEW in v3.0.1 - DCT color mode - dct_color_mode = request.form.get('dct_color_mode', 'color') - if dct_color_mode not in ('grayscale', 'color'): - dct_color_mode = 'color' + dct_color_mode = request.form.get("dct_color_mode", "color") + if dct_color_mode not in ("grayscale", "color"): + dct_color_mode = "color" # NEW in v4.0.0 - Channel key - channel_key = resolve_channel_key_form(request.form.get('channel_key', 'auto')) + channel_key = resolve_channel_key_form(request.form.get("channel_key", "auto")) # Check DCT availability - if embed_mode == 'dct' and not has_dct_support(): - flash('DCT mode requires scipy. Install with: pip install scipy', 'error') - return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) + if embed_mode == "dct" and not has_dct_support(): + flash("DCT mode requires scipy. Install with: pip install scipy", "error") + return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ) # Determine payload - if payload_type == 'file' and payload_file and payload_file.filename: + if payload_type == "file" and payload_file and payload_file.filename: # File payload file_data = payload_file.read() result = validate_file_payload(file_data, payload_file.filename) if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) + flash(result.error_message, "error") + return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ) mime_type, _ = mimetypes.guess_type(payload_file.filename) payload = FilePayload( - data=file_data, - filename=payload_file.filename, - mime_type=mime_type + data=file_data, filename=payload_file.filename, mime_type=mime_type ) else: # Text message result = validate_message(message) if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) + flash(result.error_message, "error") + return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ) payload = message # v3.2.0: Renamed from day_phrase if not passphrase: - flash('Passphrase is required', 'error') - return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) + flash("Passphrase is required", "error") + return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ) # v3.2.0: Validate passphrase result = validate_passphrase(passphrase) if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) + flash(result.error_message, "error") + return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ) # Show warning if passphrase is short if result.warning: - flash(result.warning, 'warning') + flash(result.warning, "warning") # Read files ref_data = ref_photo.read() @@ -782,7 +795,7 @@ def encode_page(): # Handle RSA key - can come from .pem file or QR code image rsa_key_data = None - rsa_key_qr = request.files.get('rsa_key_qr') + rsa_key_qr = request.files.get("rsa_key_qr") rsa_key_from_qr = False if rsa_key_file and rsa_key_file.filename: @@ -791,24 +804,24 @@ def encode_page(): qr_image_data = rsa_key_qr.read() key_pem = extract_key_from_qr(qr_image_data) if key_pem: - rsa_key_data = key_pem.encode('utf-8') + rsa_key_data = key_pem.encode("utf-8") rsa_key_from_qr = True else: - flash('Could not extract RSA key from QR code image.', 'error') - return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) + flash("Could not extract RSA key from QR code image.", "error") + return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ) # Validate security factors result = validate_security_factors(pin, rsa_key_data) if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) + flash(result.error_message, "error") + return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ) # Validate PIN if provided if pin: result = validate_pin(pin) if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) + flash(result.error_message, "error") + return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ) # Determine key password key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) @@ -817,18 +830,18 @@ def encode_page(): if rsa_key_data: result = validate_rsa_key(rsa_key_data, key_password) if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) + flash(result.error_message, "error") + return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ) # Validate carrier image result = validate_image(carrier_data, "Carrier image") if not result.is_valid: - flash(result.error_message, 'error') - return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) + flash(result.error_message, "error") + return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ) # v4.0.0: Include channel_key parameter # Use subprocess-isolated encode to prevent crashes - if payload_type == 'file' and payload_file and payload_file.filename: + if payload_type == "file" and payload_file and payload_file.filename: encode_result = subprocess_stego.encode( carrier_data=carrier_data, reference_data=ref_data, @@ -840,8 +853,8 @@ def encode_page(): rsa_key_data=rsa_key_data, rsa_password=key_password, embed_mode=embed_mode, - dct_output_format=dct_output_format if embed_mode == 'dct' else 'png', - dct_color_mode=dct_color_mode if embed_mode == 'dct' else 'color', + dct_output_format=dct_output_format if embed_mode == "dct" else "png", + dct_color_mode=dct_color_mode if embed_mode == "dct" else "color", channel_key=channel_key, # v4.0.0 ) else: @@ -854,141 +867,140 @@ def encode_page(): rsa_key_data=rsa_key_data, rsa_password=key_password, embed_mode=embed_mode, - dct_output_format=dct_output_format if embed_mode == 'dct' else 'png', - dct_color_mode=dct_color_mode if embed_mode == 'dct' else 'color', + dct_output_format=dct_output_format if embed_mode == "dct" else "png", + dct_color_mode=dct_color_mode if embed_mode == "dct" else "color", channel_key=channel_key, # v4.0.0 ) # Check for subprocess errors if not encode_result.success: - error_msg = encode_result.error or 'Encoding failed' - if 'capacity' in error_msg.lower(): + error_msg = encode_result.error or "Encoding failed" + if "capacity" in error_msg.lower(): raise CapacityError(error_msg) raise StegasooError(error_msg) # Determine actual output format for filename and storage - if embed_mode == 'dct' and dct_output_format == 'jpeg': - output_ext = '.jpg' - output_mime = 'image/jpeg' + if embed_mode == "dct" and dct_output_format == "jpeg": + output_ext = ".jpg" + output_mime = "image/jpeg" else: - output_ext = '.png' - output_mime = 'image/png' + output_ext = ".png" + output_mime = "image/png" # Use filename from result or generate one filename = encode_result.filename if not filename: - filename = generate_filename('stego', output_ext) - elif embed_mode == 'dct' and dct_output_format == 'jpeg' and filename.endswith('.png'): - filename = filename[:-4] + '.jpg' + filename = generate_filename("stego", output_ext) + elif embed_mode == "dct" and dct_output_format == "jpeg" and filename.endswith(".png"): + filename = filename[:-4] + ".jpg" # Store temporarily file_id = secrets.token_urlsafe(16) cleanup_temp_files() TEMP_FILES[file_id] = { - 'data': encode_result.stego_data, - 'filename': filename, - 'timestamp': time.time(), - 'embed_mode': embed_mode, - 'output_format': dct_output_format if embed_mode == 'dct' else 'png', - 'color_mode': dct_color_mode if embed_mode == 'dct' else None, - 'mime_type': output_mime, + "data": encode_result.stego_data, + "filename": filename, + "timestamp": time.time(), + "embed_mode": embed_mode, + "output_format": dct_output_format if embed_mode == "dct" else "png", + "color_mode": dct_color_mode if embed_mode == "dct" else None, + "mime_type": output_mime, # Channel info (v4.0.0) - 'channel_mode': encode_result.channel_mode, - 'channel_fingerprint': encode_result.channel_fingerprint, + "channel_mode": encode_result.channel_mode, + "channel_fingerprint": encode_result.channel_fingerprint, } - return redirect(url_for('encode_result', file_id=file_id)) + return redirect(url_for("encode_result", file_id=file_id)) except CapacityError as e: - flash(str(e), 'error') - return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) + flash(str(e), "error") + return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ) except StegasooError as e: - flash(str(e), 'error') - return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) + flash(str(e), "error") + return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ) except Exception as e: - flash(f'Error: {e}', 'error') - return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) + flash(f"Error: {e}", "error") + return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ) - return render_template('encode.html', has_qrcode_read=HAS_QRCODE_READ) + return render_template("encode.html", has_qrcode_read=HAS_QRCODE_READ) -@app.route('/encode/result/') +@app.route("/encode/result/") def encode_result(file_id): if file_id not in TEMP_FILES: - flash('File expired or not found. Please encode again.', 'error') - return redirect(url_for('encode_page')) + flash("File expired or not found. Please encode again.", "error") + return redirect(url_for("encode_page")) file_info = TEMP_FILES[file_id] # Generate thumbnail - thumbnail_data = generate_thumbnail(file_info['data']) + thumbnail_data = generate_thumbnail(file_info["data"]) thumbnail_id = None if thumbnail_data: thumbnail_id = f"{file_id}_thumb" THUMBNAIL_FILES[thumbnail_id] = thumbnail_data - return render_template('encode_result.html', + return render_template( + "encode_result.html", file_id=file_id, - filename=file_info['filename'], - thumbnail_url=url_for('encode_thumbnail', thumb_id=thumbnail_id) if thumbnail_id else None, - embed_mode=file_info.get('embed_mode', 'lsb'), - output_format=file_info.get('output_format', 'png'), - color_mode=file_info.get('color_mode'), + filename=file_info["filename"], + thumbnail_url=url_for("encode_thumbnail", thumb_id=thumbnail_id) if thumbnail_id else None, + embed_mode=file_info.get("embed_mode", "lsb"), + output_format=file_info.get("output_format", "png"), + color_mode=file_info.get("color_mode"), # Channel info (v4.0.0) - channel_mode=file_info.get('channel_mode', 'public'), - channel_fingerprint=file_info.get('channel_fingerprint'), + channel_mode=file_info.get("channel_mode", "public"), + channel_fingerprint=file_info.get("channel_fingerprint"), ) -@app.route('/encode/thumbnail/') +@app.route("/encode/thumbnail/") def encode_thumbnail(thumb_id): """Serve thumbnail image.""" if thumb_id not in THUMBNAIL_FILES: return "Thumbnail not found", 404 return send_file( - io.BytesIO(THUMBNAIL_FILES[thumb_id]), - mimetype='image/jpeg', - as_attachment=False + io.BytesIO(THUMBNAIL_FILES[thumb_id]), mimetype="image/jpeg", as_attachment=False ) -@app.route('/encode/download/') +@app.route("/encode/download/") def encode_download(file_id): if file_id not in TEMP_FILES: - flash('File expired or not found.', 'error') - return redirect(url_for('encode_page')) + flash("File expired or not found.", "error") + return redirect(url_for("encode_page")) file_info = TEMP_FILES[file_id] - mime_type = file_info.get('mime_type', 'image/png') + mime_type = file_info.get("mime_type", "image/png") return send_file( - io.BytesIO(file_info['data']), + io.BytesIO(file_info["data"]), mimetype=mime_type, as_attachment=True, - download_name=file_info['filename'] + download_name=file_info["filename"], ) -@app.route('/encode/file/') +@app.route("/encode/file/") def encode_file_route(file_id): """Serve file for Web Share API.""" if file_id not in TEMP_FILES: return "Not found", 404 file_info = TEMP_FILES[file_id] - mime_type = file_info.get('mime_type', 'image/png') + mime_type = file_info.get("mime_type", "image/png") return send_file( - io.BytesIO(file_info['data']), + io.BytesIO(file_info["data"]), mimetype=mime_type, as_attachment=False, - download_name=file_info['filename'] + download_name=file_info["filename"], ) -@app.route('/encode/cleanup/', methods=['POST']) +@app.route("/encode/cleanup/", methods=["POST"]) def encode_cleanup(file_id): """Manually cleanup a file after sharing.""" TEMP_FILES.pop(file_id, None) @@ -997,50 +1009,51 @@ def encode_cleanup(file_id): thumb_id = f"{file_id}_thumb" THUMBNAIL_FILES.pop(thumb_id, None) - return jsonify({'status': 'ok'}) + return jsonify({"status": "ok"}) # ============================================================================ # DECODE # ============================================================================ -@app.route('/decode', methods=['GET', 'POST']) + +@app.route("/decode", methods=["GET", "POST"]) def decode_page(): - if request.method == 'POST': + if request.method == "POST": try: # Get files - ref_photo = request.files.get('reference_photo') - stego_image = request.files.get('stego_image') - rsa_key_file = request.files.get('rsa_key') + ref_photo = request.files.get("reference_photo") + stego_image = request.files.get("stego_image") + rsa_key_file = request.files.get("rsa_key") if not ref_photo or not stego_image: - flash('Both reference photo and stego image are required', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + flash("Both reference photo and stego image are required", "error") + return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ) # Get form data - v3.2.0: renamed from day_phrase to passphrase - passphrase = request.form.get('passphrase', '') # v3.2.0: Renamed - pin = request.form.get('pin', '').strip() - rsa_password = request.form.get('rsa_password', '') + passphrase = request.form.get("passphrase", "") # v3.2.0: Renamed + pin = request.form.get("pin", "").strip() + rsa_password = request.form.get("rsa_password", "") # NEW in v3.0 - Extraction mode - embed_mode = request.form.get('embed_mode', 'auto') - if embed_mode not in ('auto', 'lsb', 'dct'): - embed_mode = 'auto' + embed_mode = request.form.get("embed_mode", "auto") + if embed_mode not in ("auto", "lsb", "dct"): + embed_mode = "auto" # NEW in v4.0.0 - Channel key - channel_key = resolve_channel_key_form(request.form.get('channel_key', 'auto')) + channel_key = resolve_channel_key_form(request.form.get("channel_key", "auto")) # Check DCT availability - if embed_mode == 'dct' and not has_dct_support(): - flash('DCT mode requires scipy. Install with: pip install scipy', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + if embed_mode == "dct" and not has_dct_support(): + flash("DCT mode requires scipy. Install with: pip install scipy", "error") + return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ) # v3.2.0: Removed date handling (no stego_date needed) # v3.2.0: Renamed from day_phrase if not passphrase: - flash('Passphrase is required', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + flash("Passphrase is required", "error") + return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ) # Read files ref_data = ref_photo.read() @@ -1048,7 +1061,7 @@ def decode_page(): # Handle RSA key - can come from .pem file or QR code image rsa_key_data = None - rsa_key_qr = request.files.get('rsa_key_qr') + rsa_key_qr = request.files.get("rsa_key_qr") rsa_key_from_qr = False if rsa_key_file and rsa_key_file.filename: @@ -1057,24 +1070,24 @@ def decode_page(): qr_image_data = rsa_key_qr.read() key_pem = extract_key_from_qr(qr_image_data) if key_pem: - rsa_key_data = key_pem.encode('utf-8') + rsa_key_data = key_pem.encode("utf-8") rsa_key_from_qr = True else: - flash('Could not extract RSA key from QR code image.', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + flash("Could not extract RSA key from QR code image.", "error") + return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ) # Validate security factors result = validate_security_factors(pin, rsa_key_data) if not result.is_valid: - flash(result.error_message, 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + flash(result.error_message, "error") + return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ) # Validate PIN if provided if pin: result = validate_pin(pin) if not result.is_valid: - flash(result.error_message, 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + flash(result.error_message, "error") + return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ) # Determine key password key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) @@ -1083,8 +1096,8 @@ def decode_page(): if rsa_key_data: result = validate_rsa_key(rsa_key_data, key_password) if not result.is_valid: - flash(result.error_message, 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + flash(result.error_message, "error") + return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ) # v4.0.0: Include channel_key parameter # Use subprocess-isolated decode to prevent crashes @@ -1101,12 +1114,12 @@ def decode_page(): # Check for subprocess errors if not decode_result.success: - error_msg = decode_result.error or 'Decoding failed' + error_msg = decode_result.error or "Decoding failed" # Check for channel key related errors - if 'channel key' in error_msg.lower(): - flash(error_msg, 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - if 'decrypt' in error_msg.lower() or decode_result.error_type == 'DecryptionError': + if "channel key" in error_msg.lower(): + flash(error_msg, "error") + return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ) + if "decrypt" in error_msg.lower() or decode_result.error_type == "DecryptionError": raise DecryptionError(error_msg) raise StegasooError(error_msg) @@ -1115,76 +1128,79 @@ def decode_page(): file_id = secrets.token_urlsafe(16) cleanup_temp_files() - filename = decode_result.filename or 'decoded_file' + filename = decode_result.filename or "decoded_file" TEMP_FILES[file_id] = { - 'data': decode_result.file_data, - 'filename': filename, - 'mime_type': decode_result.mime_type, - 'timestamp': time.time() + "data": decode_result.file_data, + "filename": filename, + "mime_type": decode_result.mime_type, + "timestamp": time.time(), } - return render_template('decode.html', + return render_template( + "decode.html", decoded_file=True, file_id=file_id, filename=filename, file_size=format_size(len(decode_result.file_data)), mime_type=decode_result.mime_type, - has_qrcode_read=HAS_QRCODE_READ + has_qrcode_read=HAS_QRCODE_READ, ) else: # Text content - return render_template('decode.html', + return render_template( + "decode.html", decoded_message=decode_result.message, - has_qrcode_read=HAS_QRCODE_READ + has_qrcode_read=HAS_QRCODE_READ, ) except DecryptionError: - flash('Decryption failed. Check your passphrase, PIN, RSA key, reference photo, and channel key.', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + flash( + "Decryption failed. Check your passphrase, PIN, RSA key, reference photo, and channel key.", + "error", + ) + return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ) except StegasooError as e: - flash(str(e), 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + flash(str(e), "error") + return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ) except Exception as e: - flash(f'Error: {e}', 'error') - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + flash(f"Error: {e}", "error") + return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ) - return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + return render_template("decode.html", has_qrcode_read=HAS_QRCODE_READ) -@app.route('/decode/download/') +@app.route("/decode/download/") def decode_download(file_id): """Download decoded file.""" if file_id not in TEMP_FILES: - flash('File expired or not found.', 'error') - return redirect(url_for('decode_page')) + flash("File expired or not found.", "error") + return redirect(url_for("decode_page")) file_info = TEMP_FILES[file_id] - mime_type = file_info.get('mime_type', 'application/octet-stream') + mime_type = file_info.get("mime_type", "application/octet-stream") return send_file( - io.BytesIO(file_info['data']), + io.BytesIO(file_info["data"]), mimetype=mime_type, as_attachment=True, - download_name=file_info['filename'] + download_name=file_info["filename"], ) -@app.route('/about') +@app.route("/about") def about(): - return render_template('about.html', - has_argon2=has_argon2(), - has_qrcode_read=HAS_QRCODE_READ - ) + return render_template("about.html", has_argon2=has_argon2(), has_qrcode_read=HAS_QRCODE_READ) # Add these two test routes anywhere in app.py after the app = Flask(...) line: -@app.route('/test-capacity', methods=['POST']) + +@app.route("/test-capacity", methods=["POST"]) def test_capacity(): """Minimal capacity test - no stegasoo code, just PIL.""" - carrier = request.files.get('carrier') + carrier = request.files.get("carrier") if not carrier: - return jsonify({'error': 'No carrier image provided'}), 400 + return jsonify({"error": "No carrier image provided"}), 400 try: carrier_data = carrier.read() @@ -1199,35 +1215,39 @@ def test_capacity(): lsb_bytes = (pixels * 3) // 8 dct_bytes = ((width // 8) * (height // 8) * 16) // 8 - 10 - return jsonify({ - 'success': True, - 'width': width, - 'height': height, - 'format': fmt, - 'lsb_kb': round(lsb_bytes / 1024, 1), - 'dct_kb': round(dct_bytes / 1024, 1), - }) + return jsonify( + { + "success": True, + "width": width, + "height": height, + "format": fmt, + "lsb_kb": round(lsb_bytes / 1024, 1), + "dct_kb": round(dct_bytes / 1024, 1), + } + ) except Exception as e: - return jsonify({'error': str(e)}), 500 + return jsonify({"error": str(e)}), 500 -@app.route('/test-capacity-nopil', methods=['POST']) +@app.route("/test-capacity-nopil", methods=["POST"]) def test_capacity_nopil(): """Ultra-minimal test - no PIL, no stegasoo.""" - carrier = request.files.get('carrier') + carrier = request.files.get("carrier") if not carrier: - return jsonify({'error': 'No carrier image provided'}), 400 + return jsonify({"error": "No carrier image provided"}), 400 carrier_data = carrier.read() - return jsonify({ - 'success': True, - 'data_size': len(carrier_data), - }) + return jsonify( + { + "success": True, + "data_size": len(carrier_data), + } + ) # ============================================================================ # MAIN # ============================================================================ -if __name__ == '__main__': - app.run(host='0.0.0.0', port=5000, debug=False) +if __name__ == "__main__": + app.run(host="0.0.0.0", port=5000, debug=False) diff --git a/frontends/web/stego_worker.py b/frontends/web/stego_worker.py index 6e1adfb..62696c7 100644 --- a/frontends/web/stego_worker.py +++ b/frontends/web/stego_worker.py @@ -24,7 +24,7 @@ import traceback from pathlib import Path # Ensure stegasoo is importable -sys.path.insert(0, str(Path(__file__).parent.parent.parent / 'src')) +sys.path.insert(0, str(Path(__file__).parent.parent.parent / "src")) sys.path.insert(0, str(Path(__file__).parent)) @@ -66,7 +66,7 @@ def _get_channel_info(resolved_key): # Auto mode - check server config if has_channel_key(): status = get_channel_status() - return "private", status.get('fingerprint') + return "private", status.get("fingerprint") return "public", None @@ -76,62 +76,62 @@ def encode_operation(params: dict) -> dict: from stegasoo import FilePayload, encode # Decode base64 inputs - carrier_data = base64.b64decode(params['carrier_b64']) - reference_data = base64.b64decode(params['reference_b64']) + carrier_data = base64.b64decode(params["carrier_b64"]) + reference_data = base64.b64decode(params["reference_b64"]) # Optional RSA key rsa_key_data = None - if params.get('rsa_key_b64'): - rsa_key_data = base64.b64decode(params['rsa_key_b64']) + if params.get("rsa_key_b64"): + rsa_key_data = base64.b64decode(params["rsa_key_b64"]) # Determine payload type - if params.get('file_b64'): - file_data = base64.b64decode(params['file_b64']) + if params.get("file_b64"): + file_data = base64.b64decode(params["file_b64"]) payload = FilePayload( data=file_data, - filename=params.get('file_name', 'file'), - mime_type=params.get('file_mime', 'application/octet-stream'), + filename=params.get("file_name", "file"), + mime_type=params.get("file_mime", "application/octet-stream"), ) else: - payload = params.get('message', '') + payload = params.get("message", "") # Resolve channel key (v4.0.0) - resolved_channel_key = _resolve_channel_key(params.get('channel_key', 'auto')) + resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto")) # Call encode with correct parameter names result = encode( message=payload, reference_photo=reference_data, carrier_image=carrier_data, - passphrase=params.get('passphrase', ''), - pin=params.get('pin'), + passphrase=params.get("passphrase", ""), + pin=params.get("pin"), rsa_key_data=rsa_key_data, - rsa_password=params.get('rsa_password'), - embed_mode=params.get('embed_mode', 'lsb'), - dct_output_format=params.get('dct_output_format', 'png'), - dct_color_mode=params.get('dct_color_mode', 'color'), + rsa_password=params.get("rsa_password"), + embed_mode=params.get("embed_mode", "lsb"), + dct_output_format=params.get("dct_output_format", "png"), + dct_color_mode=params.get("dct_color_mode", "color"), channel_key=resolved_channel_key, # v4.0.0 ) # Build stats dict if available stats = None - if hasattr(result, 'stats') and result.stats: + if hasattr(result, "stats") and result.stats: stats = { - 'pixels_modified': getattr(result.stats, 'pixels_modified', 0), - 'capacity_used': getattr(result.stats, 'capacity_used', 0), - 'bytes_embedded': getattr(result.stats, 'bytes_embedded', 0), + "pixels_modified": getattr(result.stats, "pixels_modified", 0), + "capacity_used": getattr(result.stats, "capacity_used", 0), + "bytes_embedded": getattr(result.stats, "bytes_embedded", 0), } # Get channel info for response (v4.0.0) channel_mode, channel_fingerprint = _get_channel_info(resolved_channel_key) return { - 'success': True, - 'stego_b64': base64.b64encode(result.stego_image).decode('ascii'), - 'filename': getattr(result, 'filename', None), - 'stats': stats, - 'channel_mode': channel_mode, - 'channel_fingerprint': channel_fingerprint, + "success": True, + "stego_b64": base64.b64encode(result.stego_image).decode("ascii"), + "filename": getattr(result, "filename", None), + "stats": stats, + "channel_mode": channel_mode, + "channel_fingerprint": channel_fingerprint, } @@ -140,42 +140,42 @@ def decode_operation(params: dict) -> dict: from stegasoo import decode # Decode base64 inputs - stego_data = base64.b64decode(params['stego_b64']) - reference_data = base64.b64decode(params['reference_b64']) + stego_data = base64.b64decode(params["stego_b64"]) + reference_data = base64.b64decode(params["reference_b64"]) # Optional RSA key rsa_key_data = None - if params.get('rsa_key_b64'): - rsa_key_data = base64.b64decode(params['rsa_key_b64']) + if params.get("rsa_key_b64"): + rsa_key_data = base64.b64decode(params["rsa_key_b64"]) # Resolve channel key (v4.0.0) - resolved_channel_key = _resolve_channel_key(params.get('channel_key', 'auto')) + resolved_channel_key = _resolve_channel_key(params.get("channel_key", "auto")) # Call decode with correct parameter names result = decode( stego_image=stego_data, reference_photo=reference_data, - passphrase=params.get('passphrase', ''), - pin=params.get('pin'), + passphrase=params.get("passphrase", ""), + pin=params.get("pin"), rsa_key_data=rsa_key_data, - rsa_password=params.get('rsa_password'), - embed_mode=params.get('embed_mode', 'auto'), + rsa_password=params.get("rsa_password"), + embed_mode=params.get("embed_mode", "auto"), channel_key=resolved_channel_key, # v4.0.0 ) if result.is_file: return { - 'success': True, - 'is_file': True, - 'file_b64': base64.b64encode(result.file_data).decode('ascii'), - 'filename': result.filename, - 'mime_type': result.mime_type, + "success": True, + "is_file": True, + "file_b64": base64.b64encode(result.file_data).decode("ascii"), + "filename": result.filename, + "mime_type": result.mime_type, } else: return { - 'success': True, - 'is_file': False, - 'message': result.message, + "success": True, + "is_file": False, + "message": result.message, } @@ -183,12 +183,12 @@ def compare_operation(params: dict) -> dict: """Handle compare_modes operation.""" from stegasoo import compare_modes - carrier_data = base64.b64decode(params['carrier_b64']) + carrier_data = base64.b64decode(params["carrier_b64"]) result = compare_modes(carrier_data) return { - 'success': True, - 'comparison': result, + "success": True, + "comparison": result, } @@ -196,17 +196,17 @@ def capacity_check_operation(params: dict) -> dict: """Handle will_fit_by_mode operation.""" from stegasoo import will_fit_by_mode - carrier_data = base64.b64decode(params['carrier_b64']) + carrier_data = base64.b64decode(params["carrier_b64"]) result = will_fit_by_mode( - payload=params['payload_size'], + payload=params["payload_size"], carrier_image=carrier_data, - embed_mode=params.get('embed_mode', 'lsb'), + embed_mode=params.get("embed_mode", "lsb"), ) return { - 'success': True, - 'result': result, + "success": True, + "result": result, } @@ -215,17 +215,17 @@ def channel_status_operation(params: dict) -> dict: from stegasoo import get_channel_status status = get_channel_status() - reveal = params.get('reveal', False) + reveal = params.get("reveal", False) return { - 'success': True, - 'status': { - 'mode': status['mode'], - 'configured': status['configured'], - 'fingerprint': status.get('fingerprint'), - 'source': status.get('source'), - 'key': status.get('key') if reveal and status['configured'] else None, - } + "success": True, + "status": { + "mode": status["mode"], + "configured": status["configured"], + "fingerprint": status.get("fingerprint"), + "source": status.get("source"), + "key": status.get("key") if reveal and status["configured"] else None, + }, } @@ -236,37 +236,37 @@ def main(): input_text = sys.stdin.read() if not input_text.strip(): - output = {'success': False, 'error': 'No input provided'} + output = {"success": False, "error": "No input provided"} else: params = json.loads(input_text) - operation = params.get('operation') + operation = params.get("operation") - if operation == 'encode': + if operation == "encode": output = encode_operation(params) - elif operation == 'decode': + elif operation == "decode": output = decode_operation(params) - elif operation == 'compare': + elif operation == "compare": output = compare_operation(params) - elif operation == 'capacity': + elif operation == "capacity": output = capacity_check_operation(params) - elif operation == 'channel_status': + elif operation == "channel_status": output = channel_status_operation(params) else: - output = {'success': False, 'error': f'Unknown operation: {operation}'} + output = {"success": False, "error": f"Unknown operation: {operation}"} except json.JSONDecodeError as e: - output = {'success': False, 'error': f'Invalid JSON: {e}'} + output = {"success": False, "error": f"Invalid JSON: {e}"} except Exception as e: output = { - 'success': False, - 'error': str(e), - 'error_type': type(e).__name__, - 'traceback': traceback.format_exc(), + "success": False, + "error": str(e), + "error_type": type(e).__name__, + "traceback": traceback.format_exc(), } # Write output as JSON print(json.dumps(output), flush=True) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/frontends/web/subprocess_stego.py b/frontends/web/subprocess_stego.py index 49c4ac2..8fa027f 100644 --- a/frontends/web/subprocess_stego.py +++ b/frontends/web/subprocess_stego.py @@ -55,12 +55,13 @@ from typing import Any DEFAULT_TIMEOUT = 120 # Path to worker script - adjust if needed -WORKER_SCRIPT = Path(__file__).parent / 'stego_worker.py' +WORKER_SCRIPT = Path(__file__).parent / "stego_worker.py" @dataclass class EncodeResult: """Result from encode operation.""" + success: bool stego_data: bytes | None = None filename: str | None = None @@ -75,6 +76,7 @@ class EncodeResult: @dataclass class DecodeResult: """Result from decode operation.""" + success: bool is_file: bool = False message: str | None = None @@ -88,6 +90,7 @@ class DecodeResult: @dataclass class CompareResult: """Result from compare_modes operation.""" + success: bool width: int = 0 height: int = 0 @@ -99,6 +102,7 @@ class CompareResult: @dataclass class CapacityResult: """Result from capacity check operation.""" + success: bool fits: bool = False payload_size: int = 0 @@ -112,6 +116,7 @@ class CapacityResult: @dataclass class ChannelStatusResult: """Result from channel status check (v4.0.0).""" + success: bool mode: str = "public" configured: bool = False @@ -177,37 +182,37 @@ class SubprocessStego: if result.returncode != 0: # Worker crashed return { - 'success': False, - 'error': f'Worker crashed (exit code {result.returncode})', - 'stderr': result.stderr, + "success": False, + "error": f"Worker crashed (exit code {result.returncode})", + "stderr": result.stderr, } if not result.stdout.strip(): return { - 'success': False, - 'error': 'Worker returned empty output', - 'stderr': result.stderr, + "success": False, + "error": "Worker returned empty output", + "stderr": result.stderr, } return json.loads(result.stdout) except subprocess.TimeoutExpired: return { - 'success': False, - 'error': f'Operation timed out after {timeout} seconds', - 'error_type': 'TimeoutError', + "success": False, + "error": f"Operation timed out after {timeout} seconds", + "error_type": "TimeoutError", } except json.JSONDecodeError as e: return { - 'success': False, - 'error': f'Invalid JSON from worker: {e}', - 'raw_output': result.stdout if 'result' in dir() else None, + "success": False, + "error": f"Invalid JSON from worker: {e}", + "raw_output": result.stdout if "result" in dir() else None, } except Exception as e: return { - 'success': False, - 'error': str(e), - 'error_type': type(e).__name__, + "success": False, + "error": str(e), + "error_type": type(e).__name__, } def encode( @@ -253,43 +258,43 @@ class SubprocessStego: EncodeResult with stego_data and extension on success """ params = { - 'operation': 'encode', - 'carrier_b64': base64.b64encode(carrier_data).decode('ascii'), - 'reference_b64': base64.b64encode(reference_data).decode('ascii'), - 'message': message, - 'passphrase': passphrase, - 'pin': pin, - 'embed_mode': embed_mode, - 'dct_output_format': dct_output_format, - 'dct_color_mode': dct_color_mode, - 'channel_key': channel_key, # v4.0.0 + "operation": "encode", + "carrier_b64": base64.b64encode(carrier_data).decode("ascii"), + "reference_b64": base64.b64encode(reference_data).decode("ascii"), + "message": message, + "passphrase": passphrase, + "pin": pin, + "embed_mode": embed_mode, + "dct_output_format": dct_output_format, + "dct_color_mode": dct_color_mode, + "channel_key": channel_key, # v4.0.0 } if file_data: - params['file_b64'] = base64.b64encode(file_data).decode('ascii') - params['file_name'] = file_name - params['file_mime'] = file_mime + params["file_b64"] = base64.b64encode(file_data).decode("ascii") + params["file_name"] = file_name + params["file_mime"] = file_mime if rsa_key_data: - params['rsa_key_b64'] = base64.b64encode(rsa_key_data).decode('ascii') - params['rsa_password'] = rsa_password + params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii") + params["rsa_password"] = rsa_password result = self._run_worker(params, timeout) - if result.get('success'): + if result.get("success"): return EncodeResult( success=True, - stego_data=base64.b64decode(result['stego_b64']), - filename=result.get('filename'), - stats=result.get('stats'), - channel_mode=result.get('channel_mode'), - channel_fingerprint=result.get('channel_fingerprint'), + stego_data=base64.b64decode(result["stego_b64"]), + filename=result.get("filename"), + stats=result.get("stats"), + channel_mode=result.get("channel_mode"), + channel_fingerprint=result.get("channel_fingerprint"), ) else: return EncodeResult( success=False, - error=result.get('error', 'Unknown error'), - error_type=result.get('error_type'), + error=result.get("error", "Unknown error"), + error_type=result.get("error_type"), ) def decode( @@ -323,41 +328,41 @@ class SubprocessStego: DecodeResult with message or file_data on success """ params = { - 'operation': 'decode', - 'stego_b64': base64.b64encode(stego_data).decode('ascii'), - 'reference_b64': base64.b64encode(reference_data).decode('ascii'), - 'passphrase': passphrase, - 'pin': pin, - 'embed_mode': embed_mode, - 'channel_key': channel_key, # v4.0.0 + "operation": "decode", + "stego_b64": base64.b64encode(stego_data).decode("ascii"), + "reference_b64": base64.b64encode(reference_data).decode("ascii"), + "passphrase": passphrase, + "pin": pin, + "embed_mode": embed_mode, + "channel_key": channel_key, # v4.0.0 } if rsa_key_data: - params['rsa_key_b64'] = base64.b64encode(rsa_key_data).decode('ascii') - params['rsa_password'] = rsa_password + params["rsa_key_b64"] = base64.b64encode(rsa_key_data).decode("ascii") + params["rsa_password"] = rsa_password result = self._run_worker(params, timeout) - if result.get('success'): - if result.get('is_file'): + if result.get("success"): + if result.get("is_file"): return DecodeResult( success=True, is_file=True, - file_data=base64.b64decode(result['file_b64']), - filename=result.get('filename'), - mime_type=result.get('mime_type'), + file_data=base64.b64decode(result["file_b64"]), + filename=result.get("filename"), + mime_type=result.get("mime_type"), ) else: return DecodeResult( success=True, is_file=False, - message=result.get('message'), + message=result.get("message"), ) else: return DecodeResult( success=False, - error=result.get('error', 'Unknown error'), - error_type=result.get('error_type'), + error=result.get("error", "Unknown error"), + error_type=result.get("error_type"), ) def compare_modes( @@ -376,25 +381,25 @@ class SubprocessStego: CompareResult with capacity information """ params = { - 'operation': 'compare', - 'carrier_b64': base64.b64encode(carrier_data).decode('ascii'), + "operation": "compare", + "carrier_b64": base64.b64encode(carrier_data).decode("ascii"), } result = self._run_worker(params, timeout) - if result.get('success'): - comparison = result.get('comparison', {}) + if result.get("success"): + comparison = result.get("comparison", {}) return CompareResult( success=True, - width=comparison.get('width', 0), - height=comparison.get('height', 0), - lsb=comparison.get('lsb'), - dct=comparison.get('dct'), + width=comparison.get("width", 0), + height=comparison.get("height", 0), + lsb=comparison.get("lsb"), + dct=comparison.get("dct"), ) else: return CompareResult( success=False, - error=result.get('error', 'Unknown error'), + error=result.get("error", "Unknown error"), ) def check_capacity( @@ -417,29 +422,29 @@ class SubprocessStego: CapacityResult with fit information """ params = { - 'operation': 'capacity', - 'carrier_b64': base64.b64encode(carrier_data).decode('ascii'), - 'payload_size': payload_size, - 'embed_mode': embed_mode, + "operation": "capacity", + "carrier_b64": base64.b64encode(carrier_data).decode("ascii"), + "payload_size": payload_size, + "embed_mode": embed_mode, } result = self._run_worker(params, timeout) - if result.get('success'): - r = result.get('result', {}) + if result.get("success"): + r = result.get("result", {}) return CapacityResult( success=True, - fits=r.get('fits', False), - payload_size=r.get('payload_size', 0), - capacity=r.get('capacity', 0), - usage_percent=r.get('usage_percent', 0.0), - headroom=r.get('headroom', 0), - mode=r.get('mode', embed_mode), + fits=r.get("fits", False), + payload_size=r.get("payload_size", 0), + capacity=r.get("capacity", 0), + usage_percent=r.get("usage_percent", 0.0), + headroom=r.get("headroom", 0), + mode=r.get("mode", embed_mode), ) else: return CapacityResult( success=False, - error=result.get('error', 'Unknown error'), + error=result.get("error", "Unknown error"), ) def get_channel_status( @@ -458,26 +463,26 @@ class SubprocessStego: ChannelStatusResult with channel info """ params = { - 'operation': 'channel_status', - 'reveal': reveal, + "operation": "channel_status", + "reveal": reveal, } result = self._run_worker(params, timeout) - if result.get('success'): - status = result.get('status', {}) + if result.get("success"): + status = result.get("status", {}) return ChannelStatusResult( success=True, - mode=status.get('mode', 'public'), - configured=status.get('configured', False), - fingerprint=status.get('fingerprint'), - source=status.get('source'), - key=status.get('key') if reveal else None, + mode=status.get("mode", "public"), + configured=status.get("configured", False), + fingerprint=status.get("fingerprint"), + source=status.get("source"), + key=status.get("key") if reveal else None, ) else: return ChannelStatusResult( success=False, - error=result.get('error', 'Unknown error'), + error=result.get("error", "Unknown error"), ) diff --git a/src/main.py b/src/main.py index 1f395c1..9e56179 100644 --- a/src/main.py +++ b/src/main.py @@ -22,6 +22,7 @@ def main(): """ try: from stegasoo.cli import main as cli_main + cli_main() except ImportError as e: # Provide helpful error if dependencies are missing @@ -43,6 +44,7 @@ def version(): """Print version and exit.""" try: from stegasoo import __version__ + print(f"stegasoo {__version__}") except ImportError: print("stegasoo (version unknown)") diff --git a/src/stegasoo/__init__.py b/src/stegasoo/__init__.py index 3199ada..a0a1be1 100644 --- a/src/stegasoo/__init__.py +++ b/src/stegasoo/__init__.py @@ -60,6 +60,7 @@ try: extract_key_from_qr, generate_qr_code, ) + HAS_QR_UTILS = True except ImportError: HAS_QR_UTILS = False @@ -151,13 +152,11 @@ DCT_BYTES_PER_PIXEL = 0.125 __all__ = [ # Version "__version__", - # Core "encode", "decode", "decode_file", "decode_text", - # Generation "generate_pin", "generate_passphrase", @@ -165,7 +164,6 @@ __all__ = [ "generate_credentials", "export_rsa_key_pem", "load_rsa_key", - # Channel key management (v4.0.0) "generate_channel_key", "get_channel_key", @@ -177,28 +175,22 @@ __all__ = [ "format_channel_key", "get_active_channel_key", "get_channel_fingerprint", - # Image utilities "get_image_info", "compare_capacity", - # Utilities "generate_filename", - # Crypto "has_argon2", - # Steganography "has_dct_support", "compare_modes", "will_fit_by_mode", - # QR utilities "generate_qr_code", "extract_key_from_qr", "detect_and_crop_qr", "HAS_QR_UTILS", - # Validation "validate_reference_photo", "validate_carrier", @@ -212,7 +204,6 @@ __all__ = [ "validate_dct_output_format", "validate_dct_color_mode", "validate_channel_key", - # Models "ImageInfo", "CapacityComparison", @@ -222,7 +213,6 @@ __all__ = [ "FilePayload", "Credentials", "ValidationResult", - # Exceptions "StegasooError", "ValidationError", @@ -242,7 +232,6 @@ __all__ = [ "ExtractionError", "EmbeddingError", "InvalidHeaderError", - # Constants "FORMAT_VERSION", "MIN_PASSPHRASE_WORDS", diff --git a/src/stegasoo/batch.py b/src/stegasoo/batch.py index a818d92..98276e6 100644 --- a/src/stegasoo/batch.py +++ b/src/stegasoo/batch.py @@ -23,6 +23,7 @@ from .constants import ALLOWED_IMAGE_EXTENSIONS, LOSSLESS_FORMATS class BatchStatus(Enum): """Status of individual batch items.""" + PENDING = "pending" PROCESSING = "processing" SUCCESS = "success" @@ -33,6 +34,7 @@ class BatchStatus(Enum): @dataclass class BatchItem: """Represents a single item in a batch operation.""" + input_path: Path output_path: Path | None = None status: BatchStatus = BatchStatus.PENDING @@ -84,6 +86,7 @@ class BatchCredentials: ) result = processor.batch_encode(images, creds, message="secret") """ + reference_photo: bytes passphrase: str # v3.2.0: renamed from day_phrase pin: str = "" @@ -101,27 +104,28 @@ class BatchCredentials: } @classmethod - def from_dict(cls, data: dict) -> 'BatchCredentials': + def from_dict(cls, data: dict) -> "BatchCredentials": """ Create BatchCredentials from a dictionary. Handles both v3.2.0 format (passphrase) and legacy formats (day_phrase, phrase). """ # Handle legacy 'day_phrase' and 'phrase' keys - passphrase = data.get('passphrase') or data.get('day_phrase') or data.get('phrase', '') + passphrase = data.get("passphrase") or data.get("day_phrase") or data.get("phrase", "") return cls( - reference_photo=data['reference_photo'], + reference_photo=data["reference_photo"], passphrase=passphrase, - pin=data.get('pin', ''), - rsa_key_data=data.get('rsa_key_data'), - rsa_password=data.get('rsa_password'), + pin=data.get("pin", ""), + rsa_key_data=data.get("rsa_key_data"), + rsa_password=data.get("rsa_password"), ) @dataclass class BatchResult: """Summary of a batch operation.""" + operation: str total: int = 0 succeeded: int = 0 @@ -232,18 +236,17 @@ class BatchProcessor: yield path elif path.is_dir(): - pattern = '**/*' if recursive else '*' + pattern = "**/*" if recursive else "*" for file_path in path.glob(pattern): if file_path.is_file() and self._is_valid_image(file_path): yield file_path def _is_valid_image(self, path: Path) -> bool: """Check if path is a valid image file.""" - return path.suffix.lower().lstrip('.') in ALLOWED_IMAGE_EXTENSIONS + return path.suffix.lower().lstrip(".") in ALLOWED_IMAGE_EXTENSIONS def _normalize_credentials( - self, - credentials: dict | BatchCredentials | None + self, credentials: dict | BatchCredentials | None ) -> BatchCredentials: """ Normalize credentials to BatchCredentials object. @@ -341,7 +344,11 @@ class BatchProcessor: self._do_encode(item, message, file_payload, creds, compress) item.status = BatchStatus.SUCCESS - item.output_size = item.output_path.stat().st_size if item.output_path and item.output_path.exists() else 0 + item.output_size = ( + item.output_path.stat().st_size + if item.output_path and item.output_path.exists() + else 0 + ) item.message = f"Encoded to {item.output_path.name}" except Exception as e: @@ -412,7 +419,9 @@ class BatchProcessor: output_dir=item.output_path, credentials=creds.to_dict(), ) - item.message = decoded.get('message', '') if isinstance(decoded, dict) else str(decoded) + item.message = ( + decoded.get("message", "") if isinstance(decoded, dict) else str(decoded) + ) else: # Use stegasoo decode item.message = self._do_decode(item, creds) @@ -441,10 +450,7 @@ class BatchProcessor: completed = 0 with ThreadPoolExecutor(max_workers=self.max_workers) as executor: - futures = { - executor.submit(process_func, item): item - for item in result.items - } + futures = {executor.submit(process_func, item): item for item in result.items} for future in as_completed(futures): item = future.result() @@ -469,7 +475,7 @@ class BatchProcessor: message: str | None, file_payload: Path | None, creds: BatchCredentials, - compress: bool + compress: bool, ) -> None: """ Perform actual encoding using stegasoo.encode. @@ -555,16 +561,13 @@ class BatchProcessor: return self._mock_decode(item, creds) def _mock_encode( - self, - item: BatchItem, - message: str, - creds: BatchCredentials, - compress: bool + self, item: BatchItem, message: str, creds: BatchCredentials, compress: bool ) -> None: """Mock encode for testing - replace with actual stego.encode()""" # This is a placeholder - in real usage, you'd call your actual encode function # For now, just copy the file to simulate encoding import shutil + if item.output_path: shutil.copy(item.input_path, item.output_path) @@ -605,23 +608,27 @@ def batch_capacity_check( capacity_bits = pixels * 3 capacity_bytes = (capacity_bits // 8) - 100 # Header overhead - results.append({ - "path": str(img_path), - "dimensions": f"{width}x{height}", - "pixels": pixels, - "format": img.format, - "mode": img.mode, - "capacity_bytes": max(0, capacity_bytes), - "capacity_kb": max(0, capacity_bytes // 1024), - "valid": pixels <= MAX_IMAGE_PIXELS and img.format in LOSSLESS_FORMATS, - "warnings": _get_image_warnings(img, img_path), - }) + results.append( + { + "path": str(img_path), + "dimensions": f"{width}x{height}", + "pixels": pixels, + "format": img.format, + "mode": img.mode, + "capacity_bytes": max(0, capacity_bytes), + "capacity_kb": max(0, capacity_bytes // 1024), + "valid": pixels <= MAX_IMAGE_PIXELS and img.format in LOSSLESS_FORMATS, + "warnings": _get_image_warnings(img, img_path), + } + ) except Exception as e: - results.append({ - "path": str(img_path), - "error": str(e), - "valid": False, - }) + results.append( + { + "path": str(img_path), + "error": str(e), + "valid": False, + } + ) return results @@ -638,7 +645,7 @@ def _get_image_warnings(img, path: Path) -> list[str]: if img.size[0] * img.size[1] > MAX_IMAGE_PIXELS: warnings.append(f"Image exceeds {MAX_IMAGE_PIXELS:,} pixel limit") - if img.mode not in ('RGB', 'RGBA'): + if img.mode not in ("RGB", "RGBA"): warnings.append(f"Non-RGB mode ({img.mode}) - will be converted") return warnings @@ -646,6 +653,7 @@ def _get_image_warnings(img, path: Path) -> list[str]: # CLI-friendly functions + def print_batch_result(result: BatchResult, verbose: bool = False) -> None: """Print batch result summary to console.""" print(f"\n{'='*60}") diff --git a/src/stegasoo/channel.py b/src/stegasoo/channel.py index 45c0bb7..d81c70d 100644 --- a/src/stegasoo/channel.py +++ b/src/stegasoo/channel.py @@ -34,17 +34,17 @@ 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_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' +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 + Path("./config/channel.key"), # Project config + Path.home() / ".stegasoo" / "channel.key", # User config ] @@ -61,8 +61,8 @@ def generate_channel_key() -> str: 39 """ # Generate 32 random alphanumeric characters - alphabet = 'ABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789' - raw_key = ''.join(secrets.choice(alphabet) for _ in range(CHANNEL_KEY_LENGTH)) + 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)}") @@ -87,19 +87,17 @@ def format_channel_key(raw_key: str) -> str: "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456" """ # Remove any existing dashes, spaces, and convert to uppercase - clean = raw_key.replace('-', '').replace(' ', '').upper() + 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)})" - ) + 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): + 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)) + return "-".join(clean[i : i + 4] for i in range(0, CHANNEL_KEY_LENGTH, 4)) def validate_channel_key(key: str) -> bool: @@ -148,7 +146,7 @@ def get_channel_key() -> str | None: ... print("Public mode") """ # 1. Check environment variable - env_key = os.environ.get(CHANNEL_KEY_ENV_VAR, '').strip() + 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)}") @@ -173,7 +171,7 @@ def get_channel_key() -> str | None: return None -def set_channel_key(key: str, location: str = 'project') -> Path: +def set_channel_key(key: str, location: str = "project") -> Path: """ Save a channel key to config file. @@ -194,16 +192,16 @@ def set_channel_key(key: str, location: str = 'project') -> Path: """ formatted = format_channel_key(key) - if location == 'user': - config_path = Path.home() / '.stegasoo' / 'channel.key' + if location == "user": + config_path = Path.home() / ".stegasoo" / "channel.key" else: - config_path = Path('./config/channel.key') + 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') + config_path.write_text(formatted + "\n") # Set restrictive permissions (owner read/write only) try: @@ -215,7 +213,7 @@ def set_channel_key(key: str, location: str = 'project') -> Path: return config_path -def clear_channel_key(location: str = 'all') -> list[Path]: +def clear_channel_key(location: str = "all") -> list[Path]: """ Remove channel key configuration. @@ -232,10 +230,10 @@ def clear_channel_key(location: str = 'all') -> list[Path]: 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') + 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(): @@ -275,7 +273,7 @@ def get_channel_key_hash(key: str | None = None) -> bytes | None: # Hash the formatted key to get consistent 32 bytes formatted = format_channel_key(key) - return hashlib.sha256(formatted.encode('utf-8')).digest() + return hashlib.sha256(formatted.encode("utf-8")).digest() def get_channel_fingerprint(key: str | None = None) -> str | None: @@ -300,11 +298,11 @@ def get_channel_fingerprint(key: str | None = None) -> str | None: return None formatted = format_channel_key(key) - parts = formatted.split('-') + parts = formatted.split("-") # Show first and last group, mask the rest - masked = [parts[0]] + ['••••'] * 6 + [parts[-1]] - return '-'.join(masked) + masked = [parts[0]] + ["••••"] * 6 + [parts[-1]] + return "-".join(masked) def get_channel_status() -> dict: @@ -328,10 +326,10 @@ def get_channel_status() -> dict: if key: # Find which source provided the key - source = 'unknown' - env_key = os.environ.get(CHANNEL_KEY_ENV_VAR, '').strip() + source = "unknown" + env_key = os.environ.get(CHANNEL_KEY_ENV_VAR, "").strip() if env_key and validate_channel_key(env_key): - source = 'environment' + source = "environment" else: for config_path in CONFIG_LOCATIONS: if config_path.exists(): @@ -344,19 +342,19 @@ def get_channel_status() -> dict: continue return { - 'mode': 'private', - 'configured': True, - 'fingerprint': get_channel_fingerprint(key), - 'source': source, - 'key': key, + "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, + "mode": "public", + "configured": False, + "fingerprint": None, + "source": None, + "key": None, } @@ -378,14 +376,14 @@ def has_channel_key() -> bool: # CLI SUPPORT # ============================================================================= -if __name__ == '__main__': +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']: + if status["configured"]: print(f"Fingerprint: {status['fingerprint']}") print(f"Source: {status['source']}") else: @@ -406,17 +404,17 @@ if __name__ == '__main__': cmd = sys.argv[1].lower() - if cmd == 'generate': + if cmd == "generate": key = generate_channel_key() print("Generated channel key:") print(f" {key}") print() save = input("Save to config? [y/N]: ").strip().lower() - if save == 'y': + if save == "y": path = set_channel_key(key) print(f"Saved to: {path}") - elif cmd == 'set': + elif cmd == "set": if len(sys.argv) < 3: print("Usage: python -m stegasoo.channel set ") sys.exit(1) @@ -431,22 +429,22 @@ if __name__ == '__main__': print(f"Error: {e}") sys.exit(1) - elif cmd == 'show': + elif cmd == "show": status = get_channel_status() - if status['configured']: + 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') + 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': + elif cmd == "status": print_status() else: diff --git a/src/stegasoo/cli.py b/src/stegasoo/cli.py index eb7ac59..2b62e5e 100644 --- a/src/stegasoo/cli.py +++ b/src/stegasoo/cli.py @@ -33,12 +33,12 @@ from .constants import ( ) # Click context settings -CONTEXT_SETTINGS = dict(help_option_names=['-h', '--help']) +CONTEXT_SETTINGS = dict(help_option_names=["-h", "--help"]) @click.group(context_settings=CONTEXT_SETTINGS) -@click.version_option(__version__, '-v', '--version') -@click.option('--json', 'json_output', is_flag=True, help='Output results as JSON') +@click.version_option(__version__, "-v", "--version") +@click.option("--json", "json_output", is_flag=True, help="Output results as JSON") @click.pass_context def cli(ctx, json_output): """ @@ -47,31 +47,47 @@ def cli(ctx, json_output): Hide messages in images using PIN + passphrase security. """ ctx.ensure_object(dict) - ctx.obj['json'] = json_output + ctx.obj["json"] = json_output # ============================================================================= # ENCODE COMMANDS # ============================================================================= + @cli.command() -@click.argument('image', type=click.Path(exists=True)) -@click.option('-m', '--message', help='Message to encode') -@click.option('-f', '--file', 'file_payload', type=click.Path(exists=True), - help='File to embed instead of message') -@click.option('-o', '--output', type=click.Path(), help='Output image path') -@click.option('--passphrase', prompt=True, hide_input=True, - confirmation_prompt=True, help='Passphrase (recommend 4+ words)') -@click.option('--pin', prompt=True, hide_input=True, - confirmation_prompt=True, help='PIN code') -@click.option('--compress/--no-compress', default=True, - help='Enable/disable compression (default: enabled)') -@click.option('--algorithm', type=click.Choice(['zlib', 'lz4', 'none']), - default='zlib', help='Compression algorithm') -@click.option('--dry-run', is_flag=True, help='Show capacity usage without encoding') +@click.argument("image", type=click.Path(exists=True)) +@click.option("-m", "--message", help="Message to encode") +@click.option( + "-f", + "--file", + "file_payload", + type=click.Path(exists=True), + help="File to embed instead of message", +) +@click.option("-o", "--output", type=click.Path(), help="Output image path") +@click.option( + "--passphrase", + prompt=True, + hide_input=True, + confirmation_prompt=True, + help="Passphrase (recommend 4+ words)", +) +@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code") +@click.option( + "--compress/--no-compress", default=True, help="Enable/disable compression (default: enabled)" +) +@click.option( + "--algorithm", + type=click.Choice(["zlib", "lz4", "none"]), + default="zlib", + help="Compression algorithm", +) +@click.option("--dry-run", is_flag=True, help="Show capacity usage without encoding") @click.pass_context -def encode(ctx, image, message, file_payload, output, passphrase, pin, - compress, algorithm, dry_run): +def encode( + ctx, image, message, file_payload, output, passphrase, pin, compress, algorithm, dry_run +): """ Encode a message or file into an image. @@ -88,13 +104,13 @@ def encode(ctx, image, message, file_payload, output, passphrase, pin, # Parse compression algorithm algo_map = { - 'zlib': CompressionAlgorithm.ZLIB, - 'lz4': CompressionAlgorithm.LZ4, - 'none': CompressionAlgorithm.NONE, + "zlib": CompressionAlgorithm.ZLIB, + "lz4": CompressionAlgorithm.LZ4, + "none": CompressionAlgorithm.NONE, } compression_algo = algo_map[algorithm] if compress else CompressionAlgorithm.NONE - if algorithm == 'lz4' and not HAS_LZ4: + if algorithm == "lz4" and not HAS_LZ4: click.echo("Warning: LZ4 not available, falling back to zlib", err=True) compression_algo = CompressionAlgorithm.ZLIB @@ -103,7 +119,7 @@ def encode(ctx, image, message, file_payload, output, passphrase, pin, payload_size = Path(file_payload).stat().st_size payload_type = "file" else: - payload_size = len(message.encode('utf-8')) + payload_size = len(message.encode("utf-8")) payload_type = "text" # Get image capacity @@ -123,7 +139,7 @@ def encode(ctx, image, message, file_payload, output, passphrase, pin, "fits": payload_size < capacity_bytes, } - if ctx.obj.get('json'): + if ctx.obj.get("json"): click.echo(json.dumps(result, indent=2)) else: click.echo(f"Image: {image} ({width}x{height})") @@ -138,25 +154,29 @@ def encode(ctx, image, message, file_payload, output, passphrase, pin, # For now, show what would be done output = output or f"{Path(image).stem}_encoded.png" - if ctx.obj.get('json'): - click.echo(json.dumps({ - "status": "success", - "input": image, - "output": output, - "payload_type": payload_type, - "compression": algorithm_name(compression_algo), - }, indent=2)) + if ctx.obj.get("json"): + click.echo( + json.dumps( + { + "status": "success", + "input": image, + "output": output, + "payload_type": payload_type, + "compression": algorithm_name(compression_algo), + }, + indent=2, + ) + ) else: click.echo(f"✓ Encoded {payload_type} to {output}") click.echo(f" Compression: {algorithm_name(compression_algo)}") @cli.command() -@click.argument('image', type=click.Path(exists=True)) -@click.option('--passphrase', prompt=True, hide_input=True, help='Passphrase') -@click.option('--pin', prompt=True, hide_input=True, help='PIN code') -@click.option('-o', '--output', type=click.Path(), - help='Output path for file payloads') +@click.argument("image", type=click.Path(exists=True)) +@click.option("--passphrase", prompt=True, hide_input=True, help="Passphrase") +@click.option("--pin", prompt=True, hide_input=True, help="PIN code") +@click.option("-o", "--output", type=click.Path(), help="Output path for file payloads") @click.pass_context def decode(ctx, image, passphrase, pin, output): """ @@ -176,46 +196,68 @@ def decode(ctx, image, passphrase, pin, output): "message": "[Decoded message would appear here]", } - if ctx.obj.get('json'): + if ctx.obj.get("json"): click.echo(json.dumps(result, indent=2)) else: click.echo(f"Decoded from {image}:") - click.echo(result['message']) + click.echo(result["message"]) # ============================================================================= # BATCH COMMANDS # ============================================================================= + @cli.group() def batch(): """Batch operations on multiple images.""" pass -@batch.command('encode') -@click.argument('images', nargs=-1, required=True, type=click.Path(exists=True)) -@click.option('-m', '--message', help='Message to encode in all images') -@click.option('-f', '--file', 'file_payload', type=click.Path(exists=True), - help='File to embed in all images') -@click.option('-o', '--output-dir', type=click.Path(), - help='Output directory (default: same as input)') -@click.option('--suffix', default='_encoded', help='Output filename suffix') -@click.option('--passphrase', prompt=True, hide_input=True, - confirmation_prompt=True, help='Passphrase (recommend 4+ words)') -@click.option('--pin', prompt=True, hide_input=True, - confirmation_prompt=True, help='PIN code') -@click.option('--compress/--no-compress', default=True, - help='Enable/disable compression') -@click.option('--algorithm', type=click.Choice(['zlib', 'lz4', 'none']), - default='zlib', help='Compression algorithm') -@click.option('-r', '--recursive', is_flag=True, - help='Search directories recursively') -@click.option('-j', '--jobs', default=4, help='Parallel workers (default: 4)') -@click.option('-v', '--verbose', is_flag=True, help='Show detailed output') +@batch.command("encode") +@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True)) +@click.option("-m", "--message", help="Message to encode in all images") +@click.option( + "-f", "--file", "file_payload", type=click.Path(exists=True), help="File to embed in all images" +) +@click.option( + "-o", "--output-dir", type=click.Path(), help="Output directory (default: same as input)" +) +@click.option("--suffix", default="_encoded", help="Output filename suffix") +@click.option( + "--passphrase", + prompt=True, + hide_input=True, + confirmation_prompt=True, + help="Passphrase (recommend 4+ words)", +) +@click.option("--pin", prompt=True, hide_input=True, confirmation_prompt=True, help="PIN code") +@click.option("--compress/--no-compress", default=True, help="Enable/disable compression") +@click.option( + "--algorithm", + type=click.Choice(["zlib", "lz4", "none"]), + default="zlib", + help="Compression algorithm", +) +@click.option("-r", "--recursive", is_flag=True, help="Search directories recursively") +@click.option("-j", "--jobs", default=4, help="Parallel workers (default: 4)") +@click.option("-v", "--verbose", is_flag=True, help="Show detailed output") @click.pass_context -def batch_encode(ctx, images, message, file_payload, output_dir, suffix, - passphrase, pin, compress, algorithm, recursive, jobs, verbose): +def batch_encode( + ctx, + images, + message, + file_payload, + output_dir, + suffix, + passphrase, + pin, + compress, + algorithm, + recursive, + jobs, + verbose, +): """ Encode message into multiple images. @@ -232,7 +274,7 @@ def batch_encode(ctx, images, message, file_payload, output_dir, suffix, # Progress callback def progress(current, total, item): - if not ctx.obj.get('json'): + if not ctx.obj.get("json"): status = "✓" if item.status.value == "success" else "✗" click.echo(f"[{current}/{total}] {status} {item.input_path.name}") @@ -248,25 +290,23 @@ def batch_encode(ctx, images, message, file_payload, output_dir, suffix, credentials=credentials, compress=compress, recursive=recursive, - progress_callback=progress if not ctx.obj.get('json') else None, + progress_callback=progress if not ctx.obj.get("json") else None, ) - if ctx.obj.get('json'): + if ctx.obj.get("json"): click.echo(result.to_json()) else: print_batch_result(result, verbose) -@batch.command('decode') -@click.argument('images', nargs=-1, required=True, type=click.Path(exists=True)) -@click.option('-o', '--output-dir', type=click.Path(), - help='Output directory for file payloads') -@click.option('--passphrase', prompt=True, hide_input=True, help='Passphrase') -@click.option('--pin', prompt=True, hide_input=True, help='PIN code') -@click.option('-r', '--recursive', is_flag=True, - help='Search directories recursively') -@click.option('-j', '--jobs', default=4, help='Parallel workers (default: 4)') -@click.option('-v', '--verbose', is_flag=True, help='Show detailed output') +@batch.command("decode") +@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True)) +@click.option("-o", "--output-dir", type=click.Path(), help="Output directory for file payloads") +@click.option("--passphrase", prompt=True, hide_input=True, help="Passphrase") +@click.option("--pin", prompt=True, hide_input=True, help="PIN code") +@click.option("-r", "--recursive", is_flag=True, help="Search directories recursively") +@click.option("-j", "--jobs", default=4, help="Parallel workers (default: 4)") +@click.option("-v", "--verbose", is_flag=True, help="Show detailed output") @click.pass_context def batch_decode(ctx, images, output_dir, passphrase, pin, recursive, jobs, verbose): """ @@ -282,7 +322,7 @@ def batch_decode(ctx, images, output_dir, passphrase, pin, recursive, jobs, verb # Progress callback def progress(current, total, item): - if not ctx.obj.get('json'): + if not ctx.obj.get("json"): status = "✓" if item.status.value == "success" else "✗" click.echo(f"[{current}/{total}] {status} {item.input_path.name}") @@ -294,19 +334,18 @@ def batch_decode(ctx, images, output_dir, passphrase, pin, recursive, jobs, verb output_dir=Path(output_dir) if output_dir else None, credentials=credentials, recursive=recursive, - progress_callback=progress if not ctx.obj.get('json') else None, + progress_callback=progress if not ctx.obj.get("json") else None, ) - if ctx.obj.get('json'): + if ctx.obj.get("json"): click.echo(result.to_json()) else: print_batch_result(result, verbose) -@batch.command('check') -@click.argument('images', nargs=-1, required=True, type=click.Path(exists=True)) -@click.option('-r', '--recursive', is_flag=True, - help='Search directories recursively') +@batch.command("check") +@click.argument("images", nargs=-1, required=True, type=click.Path(exists=True)) +@click.option("-r", "--recursive", is_flag=True, help="Search directories recursively") @click.pass_context def batch_check(ctx, images, recursive): """ @@ -320,22 +359,22 @@ def batch_check(ctx, images, recursive): """ results = batch_capacity_check(list(images), recursive) - if ctx.obj.get('json'): + if ctx.obj.get("json"): click.echo(json.dumps(results, indent=2)) else: click.echo(f"{'Image':<40} {'Size':<12} {'Capacity':<12} {'Status'}") click.echo("─" * 80) for item in results: - if 'error' in item: + if "error" in item: click.echo(f"{Path(item['path']).name:<40} {'ERROR':<12} {'':<12} {item['error']}") else: - name = Path(item['path']).name + name = Path(item["path"]).name if len(name) > 38: name = name[:35] + "..." - status = "✓" if item['valid'] else "⚠" - warnings = ", ".join(item.get('warnings', [])) + status = "✓" if item["valid"] else "⚠" + warnings = ", ".join(item.get("warnings", [])) click.echo( f"{name:<40} " @@ -349,11 +388,16 @@ def batch_check(ctx, images, recursive): # UTILITY COMMANDS # ============================================================================= + @cli.command() -@click.option('--words', default=DEFAULT_PASSPHRASE_WORDS, - help=f'Number of words in passphrase (default: {DEFAULT_PASSPHRASE_WORDS})') -@click.option('--pin-length', default=DEFAULT_PIN_LENGTH, - help=f'PIN length (default: {DEFAULT_PIN_LENGTH})') +@click.option( + "--words", + default=DEFAULT_PASSPHRASE_WORDS, + help=f"Number of words in passphrase (default: {DEFAULT_PASSPHRASE_WORDS})", +) +@click.option( + "--pin-length", default=DEFAULT_PIN_LENGTH, help=f"PIN length (default: {DEFAULT_PIN_LENGTH})" +) @click.pass_context def generate(ctx, words, pin_length): """ @@ -368,24 +412,37 @@ def generate(ctx, words, pin_length): import secrets # Generate PIN - pin = ''.join(str(secrets.randbelow(10)) for _ in range(pin_length)) + pin = "".join(str(secrets.randbelow(10)) for _ in range(pin_length)) # Ensure PIN doesn't start with 0 - if pin[0] == '0': + if pin[0] == "0": pin = str(secrets.randbelow(9) + 1) + pin[1:] # Generate passphrase (would use BIP-39 wordlist) # Placeholder - actual implementation uses constants.get_wordlist() try: from .constants import get_wordlist + wordlist = get_wordlist() phrase_words = [secrets.choice(wordlist) for _ in range(words)] except (ImportError, FileNotFoundError): # Fallback for testing - sample_words = ['alpha', 'bravo', 'charlie', 'delta', 'echo', 'foxtrot', - 'golf', 'hotel', 'india', 'juliet', 'kilo', 'lima'] + sample_words = [ + "alpha", + "bravo", + "charlie", + "delta", + "echo", + "foxtrot", + "golf", + "hotel", + "india", + "juliet", + "kilo", + "lima", + ] phrase_words = [secrets.choice(sample_words) for _ in range(words)] - passphrase = ' '.join(phrase_words) + passphrase = " ".join(phrase_words) result = { "passphrase": passphrase, @@ -394,7 +451,7 @@ def generate(ctx, words, pin_length): "pin_length": pin_length, } - if ctx.obj.get('json'): + if ctx.obj.get("json"): click.echo(json.dumps(result, indent=2)) else: click.echo(f"Passphrase: {passphrase}") @@ -418,7 +475,7 @@ def info(ctx): }, } - if ctx.obj.get('json'): + if ctx.obj.get("json"): click.echo(json.dumps(info_data, indent=2)) else: click.echo(f"Stegasoo v{__version__}") @@ -437,5 +494,5 @@ def main(): cli(obj={}) -if __name__ == '__main__': +if __name__ == "__main__": main() diff --git a/src/stegasoo/compression.py b/src/stegasoo/compression.py index b76757d..46d8979 100644 --- a/src/stegasoo/compression.py +++ b/src/stegasoo/compression.py @@ -12,6 +12,7 @@ from enum import IntEnum # Optional LZ4 support (faster, slightly worse ratio) try: import lz4.frame + HAS_LZ4 = True except ImportError: HAS_LZ4 = False @@ -19,13 +20,14 @@ except ImportError: class CompressionAlgorithm(IntEnum): """Supported compression algorithms.""" + NONE = 0 ZLIB = 1 LZ4 = 2 # Magic bytes for compressed payloads -COMPRESSION_MAGIC = b'\x00CMP' +COMPRESSION_MAGIC = b"\x00CMP" # Minimum size to bother compressing (small data often expands) MIN_COMPRESS_SIZE = 64 @@ -36,6 +38,7 @@ ZLIB_LEVEL = 6 class CompressionError(Exception): """Raised when compression/decompression fails.""" + pass @@ -77,7 +80,7 @@ def compress(data: bytes, algorithm: CompressionAlgorithm = CompressionAlgorithm return _wrap_uncompressed(data) # Build header: MAGIC + algorithm + original_size + compressed_data - header = COMPRESSION_MAGIC + struct.pack(' bytes: # Parse header algorithm = CompressionAlgorithm(data[4]) - original_size = struct.unpack(' bytes: # Verify size if len(result) != original_size: - raise CompressionError( - f"Size mismatch: expected {original_size}, got {len(result)}" - ) + raise CompressionError(f"Size mismatch: expected {original_size}, got {len(result)}") return result def _wrap_uncompressed(data: bytes) -> bytes: """Wrap uncompressed data with header for consistency.""" - header = COMPRESSION_MAGIC + struct.pack(' float: return len(compressed) / len(original) -def estimate_compressed_size(data: bytes, algorithm: CompressionAlgorithm = CompressionAlgorithm.ZLIB) -> int: +def estimate_compressed_size( + data: bytes, algorithm: CompressionAlgorithm = CompressionAlgorithm.ZLIB +) -> int: """ Estimate compressed size without full compression. Uses sampling for large data. diff --git a/src/stegasoo/constants.py b/src/stegasoo/constants.py index 2d537b2..7b80a2c 100644 --- a/src/stegasoo/constants.py +++ b/src/stegasoo/constants.py @@ -26,7 +26,7 @@ __version__ = "4.0.1" # FILE FORMAT # ============================================================================ -MAGIC_HEADER = b'\x89ST3' +MAGIC_HEADER = b"\x89ST3" # FORMAT VERSION HISTORY: # Version 1-3: Date-dependent encryption (v3.0.x - v3.1.x) @@ -58,21 +58,21 @@ PBKDF2_ITERATIONS = 600000 # INPUT LIMITS # ============================================================================ -MAX_IMAGE_PIXELS = 24_000_000 # ~24 megapixels -MIN_IMAGE_PIXELS = 256 * 256 # Minimum viable image size +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_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 +MAX_FILENAME_LENGTH = 255 # Max filename length to store # File size limits -MAX_FILE_SIZE = 30 * 1024 * 1024 # 30MB total file size +MAX_FILE_SIZE = 30 * 1024 * 1024 # 30MB total file size MAX_FILE_PAYLOAD_SIZE = 2 * 1024 * 1024 # 2MB payload -MAX_UPLOAD_SIZE = 30 * 1024 * 1024 # 30MB max upload (Flask) +MAX_UPLOAD_SIZE = 30 * 1024 * 1024 # 30MB max upload (Flask) # PIN configuration MIN_PIN_LENGTH = 6 @@ -119,11 +119,11 @@ QR_CROP_MIN_PADDING_PX = 10 # Minimum padding in pixels # FILE TYPES # ============================================================================ -ALLOWED_IMAGE_EXTENSIONS = {'png', 'jpg', 'jpeg', 'bmp', 'gif'} -ALLOWED_KEY_EXTENSIONS = {'pem', 'key'} +ALLOWED_IMAGE_EXTENSIONS = {"png", "jpg", "jpeg", "bmp", "gif"} +ALLOWED_KEY_EXTENSIONS = {"pem", "key"} # Lossless image formats (safe for steganography) -LOSSLESS_FORMATS = {'PNG', 'BMP', 'TIFF'} +LOSSLESS_FORMATS = {"PNG", "BMP", "TIFF"} # Supported image formats for steganography SUPPORTED_IMAGE_FORMATS = LOSSLESS_FORMATS @@ -132,7 +132,7 @@ SUPPORTED_IMAGE_FORMATS = LOSSLESS_FORMATS # DAYS (kept for organizational/UI purposes, not crypto) # ============================================================================ -DAY_NAMES = ('Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday') +DAY_NAMES = ("Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday", "Sunday") # ============================================================================ # COMPRESSION @@ -145,7 +145,7 @@ MIN_COMPRESS_SIZE = 64 ZLIB_COMPRESSION_LEVEL = 6 # Compression header magic bytes -COMPRESSION_MAGIC = b'\x00CMP' +COMPRESSION_MAGIC = b"\x00CMP" # ============================================================================ # BATCH PROCESSING @@ -164,6 +164,7 @@ BATCH_OUTPUT_SUFFIX = "_encoded" # DATA FILES # ============================================================================ + def get_data_dir() -> Path: """Get the data directory path.""" # Check multiple locations @@ -172,12 +173,12 @@ def get_data_dir() -> Path: # .parent.parent = src/ # .parent.parent.parent = project root (where data/ lives) candidates = [ - Path(__file__).parent.parent.parent / 'data', # Development: src/stegasoo -> project root - Path(__file__).parent / 'data', # Installed package - Path('/app/data'), # Docker - Path.cwd() / 'data', # Current directory - Path.cwd().parent / 'data', # One level up from cwd - Path.cwd().parent.parent / 'data', # Two levels up from cwd + Path(__file__).parent.parent.parent / "data", # Development: src/stegasoo -> project root + Path(__file__).parent / "data", # Installed package + Path("/app/data"), # Docker + Path.cwd() / "data", # Current directory + Path.cwd().parent / "data", # One level up from cwd + Path.cwd().parent.parent / "data", # Two levels up from cwd ] for path in candidates: @@ -190,7 +191,7 @@ def get_data_dir() -> Path: def get_bip39_words() -> list[str]: """Load BIP-39 wordlist.""" - wordlist_path = get_data_dir() / 'bip39-words.txt' + wordlist_path = get_data_dir() / "bip39-words.txt" if not wordlist_path.exists(): raise FileNotFoundError( @@ -219,14 +220,14 @@ def get_wordlist() -> list[str]: # ============================================================================= # Embedding modes -EMBED_MODE_LSB = 'lsb' # Spatial LSB embedding (default, original mode) -EMBED_MODE_DCT = 'dct' # DCT domain embedding (new in v3.0) -EMBED_MODE_AUTO = 'auto' # Auto-detect on decode +EMBED_MODE_LSB = "lsb" # Spatial LSB embedding (default, original mode) +EMBED_MODE_DCT = "dct" # DCT domain embedding (new in v3.0) +EMBED_MODE_AUTO = "auto" # Auto-detect on decode # DCT-specific constants -DCT_MAGIC_HEADER = b'\x89DCT' # Magic header for DCT mode +DCT_MAGIC_HEADER = b"\x89DCT" # Magic header for DCT mode DCT_FORMAT_VERSION = 1 -DCT_STEP_SIZE = 8 # QIM quantization step +DCT_STEP_SIZE = 8 # QIM quantization step # Valid embedding modes VALID_EMBED_MODES = {EMBED_MODE_LSB, EMBED_MODE_DCT} @@ -247,13 +248,13 @@ def detect_stego_mode(encrypted_data: bytes) -> str: 'lsb' or 'dct' or 'unknown' """ if len(encrypted_data) < 4: - return 'unknown' + return "unknown" header = encrypted_data[:4] - if header == b'\x89ST3': + if header == b"\x89ST3": return EMBED_MODE_LSB - elif header == b'\x89DCT': + elif header == b"\x89DCT": return EMBED_MODE_DCT else: - return 'unknown' + return "unknown" diff --git a/src/stegasoo/crypto.py b/src/stegasoo/crypto.py index abb9698..779d054 100644 --- a/src/stegasoo/crypto.py +++ b/src/stegasoo/crypto.py @@ -44,6 +44,7 @@ from .models import DecodeResult, FilePayload # Check for Argon2 availability try: from argon2.low_level import Type, hash_secret_raw + HAS_ARGON2 = True except ImportError: HAS_ARGON2 = False @@ -79,15 +80,17 @@ def _resolve_channel_key(channel_key: str | bool | None) -> bytes | None: # Auto-detect from environment/config if channel_key is None or channel_key == CHANNEL_KEY_AUTO: from .channel import get_channel_key_hash + return get_channel_key_hash() # Explicit key provided - validate and hash it if isinstance(channel_key, str): from .channel import format_channel_key, validate_channel_key + if not validate_channel_key(channel_key): raise ValueError(f"Invalid channel key format: {channel_key}") formatted = format_channel_key(channel_key) - return hashlib.sha256(formatted.encode('utf-8')).digest() + return hashlib.sha256(formatted.encode("utf-8")).digest() raise ValueError(f"Invalid channel_key type: {type(channel_key)}") @@ -96,6 +99,7 @@ def _resolve_channel_key(channel_key: str | bool | None) -> bytes | None: # CORE CRYPTO FUNCTIONS # ============================================================================= + def hash_photo(image_data: bytes) -> bytes: """ Compute deterministic hash of photo pixel content. @@ -109,7 +113,7 @@ def hash_photo(image_data: bytes) -> bytes: Returns: 32-byte SHA-256 hash """ - img: Image.Image = Image.open(io.BytesIO(image_data)).convert('RGB') + img: Image.Image = Image.open(io.BytesIO(image_data)).convert("RGB") pixels = img.tobytes() # Double-hash with prefix for additional mixing @@ -163,12 +167,7 @@ def derive_hybrid_key( channel_hash = _resolve_channel_key(channel_key) # Build key material - key_material = ( - photo_hash + - passphrase.lower().encode() + - pin.encode() + - salt - ) + key_material = photo_hash + passphrase.lower().encode() + pin.encode() + salt # Add RSA key hash if provided if rsa_key_data: @@ -186,7 +185,7 @@ def derive_hybrid_key( memory_cost=ARGON2_MEMORY_COST, parallelism=ARGON2_PARALLELISM, hash_len=32, - type=Type.ID + type=Type.ID, ) else: kdf = PBKDF2HMAC( @@ -194,7 +193,7 @@ def derive_hybrid_key( length=32, salt=salt, iterations=PBKDF2_ITERATIONS, - backend=default_backend() + backend=default_backend(), ) key = kdf.derive(key_material) @@ -232,11 +231,7 @@ def derive_pixel_key( # Resolve channel key channel_hash = _resolve_channel_key(channel_key) - material = ( - photo_hash + - passphrase.lower().encode() + - pin.encode() - ) + material = photo_hash + passphrase.lower().encode() + pin.encode() if rsa_key_data: material += hashlib.sha256(rsa_key_data).digest() @@ -268,31 +263,31 @@ def _pack_payload( """ if isinstance(content, str): # Text message - data = content.encode('utf-8') + data = content.encode("utf-8") return bytes([PAYLOAD_TEXT]) + data, PAYLOAD_TEXT elif isinstance(content, FilePayload): # File with metadata - filename = content.filename[:MAX_FILENAME_LENGTH].encode('utf-8') - mime = (content.mime_type or '')[:100].encode('utf-8') + filename = content.filename[:MAX_FILENAME_LENGTH].encode("utf-8") + mime = (content.mime_type or "")[:100].encode("utf-8") packed = ( - bytes([PAYLOAD_FILE]) + - struct.pack('>H', len(filename)) + - filename + - struct.pack('>H', len(mime)) + - mime + - content.data + bytes([PAYLOAD_FILE]) + + struct.pack(">H", len(filename)) + + filename + + struct.pack(">H", len(mime)) + + mime + + content.data ) return packed, PAYLOAD_FILE else: # Raw bytes - treat as file with no name packed = ( - bytes([PAYLOAD_FILE]) + - struct.pack('>H', 0) + # No filename - struct.pack('>H', 0) + # No mime - content + bytes([PAYLOAD_FILE]) + + struct.pack(">H", 0) # No filename + + struct.pack(">H", 0) # No mime + + content ) return packed, PAYLOAD_FILE @@ -314,42 +309,39 @@ def _unpack_payload(data: bytes) -> DecodeResult: if payload_type == PAYLOAD_TEXT: # Text message - text = data[1:].decode('utf-8') - return DecodeResult(payload_type='text', message=text) + text = data[1:].decode("utf-8") + return DecodeResult(payload_type="text", message=text) elif payload_type == PAYLOAD_FILE: # File with metadata offset = 1 # Read filename - filename_len = struct.unpack('>H', data[offset:offset+2])[0] + filename_len = struct.unpack(">H", data[offset : offset + 2])[0] offset += 2 - filename = data[offset:offset+filename_len].decode('utf-8') if filename_len else None + filename = data[offset : offset + filename_len].decode("utf-8") if filename_len else None offset += filename_len # Read mime type - mime_len = struct.unpack('>H', data[offset:offset+2])[0] + mime_len = struct.unpack(">H", data[offset : offset + 2])[0] offset += 2 - mime_type = data[offset:offset+mime_len].decode('utf-8') if mime_len else None + mime_type = data[offset : offset + mime_len].decode("utf-8") if mime_len else None offset += mime_len # Rest is file data file_data = data[offset:] return DecodeResult( - payload_type='file', - file_data=file_data, - filename=filename, - mime_type=mime_type + payload_type="file", file_data=file_data, filename=filename, mime_type=mime_type ) else: # Unknown type - try to decode as text (backward compatibility) try: - text = data.decode('utf-8') - return DecodeResult(payload_type='text', message=text) + text = data.decode("utf-8") + return DecodeResult(payload_type="text", message=text) except UnicodeDecodeError: - return DecodeResult(payload_type='file', file_data=data) + return DecodeResult(payload_type="file", file_data=data) # ============================================================================= @@ -415,7 +407,7 @@ def encrypt_message( padding_len = secrets.randbelow(256) + 64 padded_len = ((len(packed_payload) + padding_len + 255) // 256) * 256 padding_needed = padded_len - len(packed_payload) - padding = secrets.token_bytes(padding_needed - 4) + struct.pack('>I', len(packed_payload)) + padding = secrets.token_bytes(padding_needed - 4) + struct.pack(">I", len(packed_payload)) padded_message = packed_payload + padding # Build header for AAD @@ -428,13 +420,7 @@ def encrypt_message( ciphertext = encryptor.update(padded_message) + encryptor.finalize() # v4.0.0: Header with flags byte - return ( - header + - salt + - iv + - encryptor.tag + - ciphertext - ) + return header + salt + iv + encryptor.tag + ciphertext except Exception as e: raise EncryptionError(f"Encryption failed: {e}") from e @@ -464,22 +450,22 @@ def parse_header(encrypted_data: bytes) -> dict | None: flags = encrypted_data[5] offset = 6 - salt = encrypted_data[offset:offset + SALT_SIZE] + salt = encrypted_data[offset : offset + SALT_SIZE] offset += SALT_SIZE - iv = encrypted_data[offset:offset + IV_SIZE] + iv = encrypted_data[offset : offset + IV_SIZE] offset += IV_SIZE - tag = encrypted_data[offset:offset + TAG_SIZE] + tag = encrypted_data[offset : offset + TAG_SIZE] offset += TAG_SIZE ciphertext = encrypted_data[offset:] return { - 'version': version, - 'flags': flags, - 'has_channel_key': bool(flags & FLAG_CHANNEL_KEY), - 'salt': salt, - 'iv': iv, - 'tag': tag, - 'ciphertext': ciphertext + "version": version, + "flags": flags, + "has_channel_key": bool(flags & FLAG_CHANNEL_KEY), + "salt": salt, + "iv": iv, + "tag": tag, + "ciphertext": ciphertext, } except Exception: return None @@ -518,26 +504,24 @@ def decrypt_message( # Check for channel key mismatch and provide helpful error channel_hash = _resolve_channel_key(channel_key) has_configured_key = channel_hash is not None - message_has_key = header['has_channel_key'] + message_has_key = header["has_channel_key"] try: key = derive_hybrid_key( - photo_data, passphrase, header['salt'], pin, rsa_key_data, channel_key + photo_data, passphrase, header["salt"], pin, rsa_key_data, channel_key ) # Reconstruct header for AAD verification - aad_header = MAGIC_HEADER + bytes([FORMAT_VERSION, header['flags']]) + aad_header = MAGIC_HEADER + bytes([FORMAT_VERSION, header["flags"]]) cipher = Cipher( - algorithms.AES(key), - modes.GCM(header['iv'], header['tag']), - backend=default_backend() + algorithms.AES(key), modes.GCM(header["iv"], header["tag"]), backend=default_backend() ) decryptor = cipher.decryptor() decryptor.authenticate_additional_data(aad_header) - padded_plaintext = decryptor.update(header['ciphertext']) + decryptor.finalize() - original_length = struct.unpack('>I', padded_plaintext[-4:])[0] + padded_plaintext = decryptor.update(header["ciphertext"]) + decryptor.finalize() + original_length = struct.unpack(">I", padded_plaintext[-4:])[0] payload_data = padded_plaintext[:original_length] result = _unpack_payload(payload_data) @@ -596,7 +580,7 @@ def decrypt_message_text( if result.file_data: # Try to decode as text try: - return result.file_data.decode('utf-8') + return result.file_data.decode("utf-8") except UnicodeDecodeError: raise DecryptionError( f"Content is a binary file ({result.filename or 'unnamed'}), not text" @@ -615,6 +599,7 @@ def has_argon2() -> bool: # CHANNEL KEY UTILITIES (exposed for convenience) # ============================================================================= + def get_active_channel_key() -> str | None: """ Get the currently configured channel key (if any). @@ -623,6 +608,7 @@ def get_active_channel_key() -> str | None: Formatted channel key string, or None if not configured """ from .channel import get_channel_key + return get_channel_key() @@ -637,4 +623,5 @@ def get_channel_fingerprint(key: str | None = None) -> str | None: Masked key like "ABCD-••••-••••-••••-••••-••••-••••-3456" or None """ from .channel import get_channel_fingerprint as _get_fingerprint + return _get_fingerprint(key) diff --git a/src/stegasoo/dct_steganography.py b/src/stegasoo/dct_steganography.py index 9e59e16..a95e1c6 100644 --- a/src/stegasoo/dct_steganography.py +++ b/src/stegasoo/dct_steganography.py @@ -28,10 +28,12 @@ from PIL import Image # Prefer scipy.fft (newer, more stable) over scipy.fftpack try: from scipy.fft import dct, idct + HAS_SCIPY = True except ImportError: try: from scipy.fftpack import dct, idct + HAS_SCIPY = True except ImportError: HAS_SCIPY = False @@ -41,6 +43,7 @@ except ImportError: # Check for jpegio availability (for proper JPEG mode) try: import jpegio as jio + HAS_JPEGIO = True except ImportError: HAS_JPEGIO = False @@ -53,19 +56,49 @@ except ImportError: BLOCK_SIZE = 8 EMBED_POSITIONS = [ - (0, 1), (1, 0), (2, 0), (1, 1), (0, 2), (0, 3), (1, 2), (2, 1), (3, 0), - (4, 0), (3, 1), (2, 2), (1, 3), (0, 4), (0, 5), (1, 4), (2, 3), (3, 2), - (4, 1), (5, 0), (5, 1), (4, 2), (3, 3), (2, 4), (1, 5), (0, 6), (0, 7), - (1, 6), (2, 5), (3, 4), (4, 3), (5, 2), (6, 1), (7, 0), + (0, 1), + (1, 0), + (2, 0), + (1, 1), + (0, 2), + (0, 3), + (1, 2), + (2, 1), + (3, 0), + (4, 0), + (3, 1), + (2, 2), + (1, 3), + (0, 4), + (0, 5), + (1, 4), + (2, 3), + (3, 2), + (4, 1), + (5, 0), + (5, 1), + (4, 2), + (3, 3), + (2, 4), + (1, 5), + (0, 6), + (0, 7), + (1, 6), + (2, 5), + (3, 4), + (4, 3), + (5, 2), + (6, 1), + (7, 0), ] DEFAULT_EMBED_POSITIONS = EMBED_POSITIONS[4:20] QUANT_STEP = 25 -DCT_MAGIC = b'DCTS' +DCT_MAGIC = b"DCTS" HEADER_SIZE = 10 -OUTPUT_FORMAT_PNG = 'png' -OUTPUT_FORMAT_JPEG = 'jpeg' +OUTPUT_FORMAT_PNG = "png" +OUTPUT_FORMAT_JPEG = "jpeg" JPEG_OUTPUT_QUALITY = 95 -JPEGIO_MAGIC = b'JPGS' +JPEGIO_MAGIC = b"JPGS" JPEGIO_MIN_COEF_MAGNITUDE = 2 JPEGIO_EMBED_CHANNEL = 0 FLAG_COLOR_MODE = 0x01 @@ -83,9 +116,10 @@ JPEGIO_MAX_QUANT_VALUE_THRESHOLD = 1 # If all quant values <= this, normalize # DATA CLASSES # ============================================================================ + class DCTOutputFormat(Enum): - PNG = 'png' - JPEG = 'jpeg' + PNG = "png" + JPEG = "jpeg" @dataclass @@ -99,7 +133,7 @@ class DCTEmbedStats: image_height: int output_format: str jpeg_native: bool = False - color_mode: str = 'grayscale' + color_mode: str = "grayscale" @dataclass @@ -119,11 +153,10 @@ class DCTCapacityInfo: # AVAILABILITY CHECKS # ============================================================================ + def _check_scipy(): if not HAS_SCIPY: - raise ImportError( - "DCT steganography requires scipy. Install with: pip install scipy" - ) + raise ImportError("DCT steganography requires scipy. Install with: pip install scipy") def has_dct_support() -> bool: @@ -139,25 +172,26 @@ def has_jpegio_support() -> bool: # These create fresh arrays to avoid scipy memory corruption issues # ============================================================================ + def _safe_dct2(block: np.ndarray) -> np.ndarray: """ Apply 2D DCT with memory isolation. Creates a completely fresh array to avoid heap corruption. """ # Create a brand new array (not a view) - safe_block = np.array(block, dtype=np.float64, copy=True, order='C') + safe_block = np.array(block, dtype=np.float64, copy=True, order="C") # First DCT on columns (transpose -> DCT rows -> transpose back) - temp = np.zeros_like(safe_block, dtype=np.float64, order='C') + temp = np.zeros_like(safe_block, dtype=np.float64, order="C") for i in range(BLOCK_SIZE): col = np.array(safe_block[:, i], dtype=np.float64, copy=True) - temp[:, i] = dct(col, norm='ortho') + temp[:, i] = dct(col, norm="ortho") # Second DCT on rows - result = np.zeros_like(temp, dtype=np.float64, order='C') + result = np.zeros_like(temp, dtype=np.float64, order="C") for i in range(BLOCK_SIZE): row = np.array(temp[i, :], dtype=np.float64, copy=True) - result[i, :] = dct(row, norm='ortho') + result[i, :] = dct(row, norm="ortho") return result @@ -168,19 +202,19 @@ def _safe_idct2(block: np.ndarray) -> np.ndarray: Creates a completely fresh array to avoid heap corruption. """ # Create a brand new array (not a view) - safe_block = np.array(block, dtype=np.float64, copy=True, order='C') + safe_block = np.array(block, dtype=np.float64, copy=True, order="C") # First IDCT on rows - temp = np.zeros_like(safe_block, dtype=np.float64, order='C') + temp = np.zeros_like(safe_block, dtype=np.float64, order="C") for i in range(BLOCK_SIZE): row = np.array(safe_block[i, :], dtype=np.float64, copy=True) - temp[i, :] = idct(row, norm='ortho') + temp[i, :] = idct(row, norm="ortho") # Second IDCT on columns - result = np.zeros_like(temp, dtype=np.float64, order='C') + result = np.zeros_like(temp, dtype=np.float64, order="C") for i in range(BLOCK_SIZE): col = np.array(temp[:, i], dtype=np.float64, copy=True) - result[:, i] = idct(col, norm='ortho') + result[:, i] = idct(col, norm="ortho") return result @@ -189,20 +223,21 @@ def _safe_idct2(block: np.ndarray) -> np.ndarray: # IMAGE PROCESSING HELPERS # ============================================================================ + def _to_grayscale(image_data: bytes) -> np.ndarray: img = Image.open(io.BytesIO(image_data)) - gray = img.convert('L') - return np.array(gray, dtype=np.float64, copy=True, order='C') + gray = img.convert("L") + return np.array(gray, dtype=np.float64, copy=True, order="C") def _extract_y_channel(image_data: bytes) -> np.ndarray: img = Image.open(io.BytesIO(image_data)) - if img.mode != 'RGB': - img = img.convert('RGB') + if img.mode != "RGB": + img = img.convert("RGB") - rgb = np.array(img, dtype=np.float64, copy=True, order='C') + rgb = np.array(img, dtype=np.float64, copy=True, order="C") Y = 0.299 * rgb[:, :, 0] + 0.587 * rgb[:, :, 1] + 0.114 * rgb[:, :, 2] - return np.array(Y, dtype=np.float64, copy=True, order='C') + return np.array(Y, dtype=np.float64, copy=True, order="C") def _pad_to_blocks(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int]]: @@ -211,27 +246,27 @@ def _pad_to_blocks(image: np.ndarray) -> tuple[np.ndarray, tuple[int, int]]: new_w = ((w + BLOCK_SIZE - 1) // BLOCK_SIZE) * BLOCK_SIZE if new_h == h and new_w == w: - return np.array(image, dtype=np.float64, copy=True, order='C'), (h, w) + return np.array(image, dtype=np.float64, copy=True, order="C"), (h, w) - padded = np.zeros((new_h, new_w), dtype=np.float64, order='C') + padded = np.zeros((new_h, new_w), dtype=np.float64, order="C") padded[:h, :w] = image # Simple edge replication for padding if new_h > h: for i in range(h, new_h): - padded[i, :w] = padded[h-1, :w] + padded[i, :w] = padded[h - 1, :w] if new_w > w: for j in range(w, new_w): - padded[:h, j] = padded[:h, w-1] + padded[:h, j] = padded[:h, w - 1] if new_h > h and new_w > w: - padded[h:, w:] = padded[h-1, w-1] + padded[h:, w:] = padded[h - 1, w - 1] return padded, (h, w) def _unpad_image(image: np.ndarray, original_size: tuple[int, int]) -> np.ndarray: h, w = original_size - return np.array(image[:h, :w], dtype=np.float64, copy=True, order='C') + return np.array(image[:h, :w], dtype=np.float64, copy=True, order="C") def _embed_bit_in_coeff(coef: float, bit: int, quant_step: int = QUANT_STEP) -> float: @@ -251,7 +286,7 @@ def _extract_bit_from_coeff(coef: float, quant_step: int = QUANT_STEP) -> int: def _generate_block_order(num_blocks: int, seed: bytes) -> list: hash_bytes = hashlib.sha256(seed).digest() - rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], 'big')) + rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], "big")) order = list(range(num_blocks)) rng.shuffle(order) return order @@ -259,25 +294,23 @@ def _generate_block_order(num_blocks: int, seed: bytes) -> list: def _save_stego_image(image: np.ndarray, output_format: str = OUTPUT_FORMAT_PNG) -> bytes: clipped = np.clip(image, 0, 255).astype(np.uint8) - img = Image.fromarray(clipped, mode='L') + img = Image.fromarray(clipped, mode="L") buffer = io.BytesIO() if output_format == OUTPUT_FORMAT_JPEG: - img.save(buffer, format='JPEG', quality=JPEG_OUTPUT_QUALITY, - subsampling=0, optimize=True) + img.save(buffer, format="JPEG", quality=JPEG_OUTPUT_QUALITY, subsampling=0, optimize=True) else: - img.save(buffer, format='PNG', optimize=True) + img.save(buffer, format="PNG", optimize=True) return buffer.getvalue() def _save_color_image(rgb_array: np.ndarray, output_format: str = OUTPUT_FORMAT_PNG) -> bytes: clipped = np.clip(rgb_array, 0, 255).astype(np.uint8) - img = Image.fromarray(clipped, mode='RGB') + img = Image.fromarray(clipped, mode="RGB") buffer = io.BytesIO() if output_format == OUTPUT_FORMAT_JPEG: - img.save(buffer, format='JPEG', quality=JPEG_OUTPUT_QUALITY, - subsampling=0, optimize=True) + img.save(buffer, format="JPEG", quality=JPEG_OUTPUT_QUALITY, subsampling=0, optimize=True) else: - img.save(buffer, format='PNG', optimize=True) + img.save(buffer, format="PNG", optimize=True) return buffer.getvalue() @@ -286,9 +319,13 @@ def _rgb_to_ycbcr(rgb: np.ndarray) -> tuple[np.ndarray, np.ndarray, np.ndarray]: G = rgb[:, :, 1].astype(np.float64) B = rgb[:, :, 2].astype(np.float64) - Y = np.array(0.299 * R + 0.587 * G + 0.114 * B, dtype=np.float64, copy=True, order='C') - Cb = np.array(128 - 0.168736 * R - 0.331264 * G + 0.5 * B, dtype=np.float64, copy=True, order='C') - Cr = np.array(128 + 0.5 * R - 0.418688 * G - 0.081312 * B, dtype=np.float64, copy=True, order='C') + Y = np.array(0.299 * R + 0.587 * G + 0.114 * B, dtype=np.float64, copy=True, order="C") + Cb = np.array( + 128 - 0.168736 * R - 0.331264 * G + 0.5 * B, dtype=np.float64, copy=True, order="C" + ) + Cr = np.array( + 128 + 0.5 * R - 0.418688 * G - 0.081312 * B, dtype=np.float64, copy=True, order="C" + ) return Y, Cb, Cr @@ -298,7 +335,7 @@ def _ycbcr_to_rgb(Y: np.ndarray, Cb: np.ndarray, Cr: np.ndarray) -> np.ndarray: G = Y - 0.344136 * (Cb - 128) - 0.714136 * (Cr - 128) B = Y + 1.772 * (Cb - 128) - rgb = np.zeros((Y.shape[0], Y.shape[1], 3), dtype=np.float64, order='C') + rgb = np.zeros((Y.shape[0], Y.shape[1], 3), dtype=np.float64, order="C") rgb[:, :, 0] = R rgb[:, :, 1] = G rgb[:, :, 2] = B @@ -306,19 +343,21 @@ def _ycbcr_to_rgb(Y: np.ndarray, Cb: np.ndarray, Cr: np.ndarray) -> np.ndarray: def _create_header(data_length: int, flags: int = 0) -> bytes: - return struct.pack('>4sBBI', DCT_MAGIC, 1, flags, data_length) + return struct.pack(">4sBBI", DCT_MAGIC, 1, flags, data_length) def _parse_header(header_bits: list) -> tuple[int, int, int]: if len(header_bits) < HEADER_SIZE * 8: raise ValueError("Insufficient header data") - header_bytes = bytes([ - sum(header_bits[i*8:(i+1)*8][j] << (7-j) for j in range(8)) - for i in range(HEADER_SIZE) - ]) + header_bytes = bytes( + [ + sum(header_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8)) + for i in range(HEADER_SIZE) + ] + ) - magic, version, flags, length = struct.unpack('>4sBBI', header_bytes) + magic, version, flags, length = struct.unpack(">4sBBI", header_bytes) if magic != DCT_MAGIC: raise ValueError("Invalid DCT stego magic bytes") @@ -330,9 +369,11 @@ def _parse_header(header_bits: list) -> tuple[int, int, int]: # JPEGIO HELPERS # ============================================================================ -def _jpegio_bytes_to_file(data: bytes, suffix: str = '.jpg') -> str: + +def _jpegio_bytes_to_file(data: bytes, suffix: str = ".jpg") -> str: import os import tempfile + fd, path = tempfile.mkstemp(suffix=suffix) try: os.write(fd, data) @@ -355,20 +396,20 @@ def _jpegio_get_usable_positions(coef_array: np.ndarray) -> list: def _jpegio_generate_order(num_positions: int, seed: bytes) -> list: hash_bytes = hashlib.sha256(seed + b"jpeg_coef_order").digest() - rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], 'big')) + rng = np.random.RandomState(int.from_bytes(hash_bytes[:4], "big")) order = list(range(num_positions)) rng.shuffle(order) return order def _jpegio_create_header(data_length: int, flags: int = 0) -> bytes: - return struct.pack('>4sBBI', JPEGIO_MAGIC, 1, flags, data_length) + return struct.pack(">4sBBI", JPEGIO_MAGIC, 1, flags, data_length) def _jpegio_parse_header(header_bytes: bytes) -> tuple[int, int, int]: if len(header_bytes) < HEADER_SIZE: raise ValueError("Insufficient header data") - magic, version, flags, length = struct.unpack('>4sBBI', header_bytes[:HEADER_SIZE]) + magic, version, flags, length = struct.unpack(">4sBBI", header_bytes[:HEADER_SIZE]) if magic != JPEGIO_MAGIC: raise ValueError(f"Invalid JPEG stego magic: {magic}") return version, flags, length @@ -378,6 +419,7 @@ def _jpegio_parse_header(header_bytes: bytes) -> tuple[int, int, int]: # PUBLIC API # ============================================================================ + def calculate_dct_capacity(image_data: bytes) -> DCTCapacityInfo: """Calculate DCT embedding capacity of an image.""" _check_scipy() @@ -405,7 +447,7 @@ def calculate_dct_capacity(image_data: bytes) -> DCTCapacityInfo: bits_per_block=bits_per_block, total_capacity_bits=total_bits, total_capacity_bytes=total_bytes, - usable_capacity_bytes=usable_bytes + usable_capacity_bytes=usable_bytes, ) @@ -427,24 +469,24 @@ def estimate_capacity_comparison(image_data: bytes) -> dict: dct_bytes = (blocks * 16) // 8 - HEADER_SIZE return { - 'width': width, - 'height': height, - 'lsb': { - 'capacity_bytes': lsb_bytes, - 'capacity_kb': lsb_bytes / 1024, - 'output': 'PNG/BMP (color)', + "width": width, + "height": height, + "lsb": { + "capacity_bytes": lsb_bytes, + "capacity_kb": lsb_bytes / 1024, + "output": "PNG/BMP (color)", }, - 'dct': { - 'capacity_bytes': dct_bytes, - 'capacity_kb': dct_bytes / 1024, - 'output': 'PNG or JPEG (grayscale)', - 'ratio_vs_lsb': (dct_bytes / lsb_bytes * 100) if lsb_bytes > 0 else 0, - 'available': HAS_SCIPY, + "dct": { + "capacity_bytes": dct_bytes, + "capacity_kb": dct_bytes / 1024, + "output": "PNG or JPEG (grayscale)", + "ratio_vs_lsb": (dct_bytes / lsb_bytes * 100) if lsb_bytes > 0 else 0, + "available": HAS_SCIPY, + }, + "jpeg_native": { + "available": HAS_JPEGIO, + "note": "Uses jpegio for proper JPEG coefficient embedding", }, - 'jpeg_native': { - 'available': HAS_JPEGIO, - 'note': 'Uses jpegio for proper JPEG coefficient embedding', - } } @@ -453,14 +495,14 @@ def embed_in_dct( carrier_image: bytes, seed: bytes, output_format: str = OUTPUT_FORMAT_PNG, - color_mode: str = 'color', + color_mode: str = "color", ) -> tuple[bytes, DCTEmbedStats]: """Embed data using DCT coefficient modification.""" if output_format not in (OUTPUT_FORMAT_PNG, OUTPUT_FORMAT_JPEG): raise ValueError(f"Invalid output format: {output_format}") - if color_mode not in ('color', 'grayscale'): - color_mode = 'color' + if color_mode not in ("color", "grayscale"): + color_mode = "color" if output_format == OUTPUT_FORMAT_JPEG and HAS_JPEGIO: return _embed_jpegio(data, carrier_image, seed, color_mode) @@ -474,7 +516,7 @@ def _embed_scipy_dct_safe( carrier_image: bytes, seed: bytes, output_format: str, - color_mode: str = 'color', + color_mode: str = "color", ) -> tuple[bytes, DCTEmbedStats]: """ Embed using scipy DCT with safe memory handling. @@ -494,7 +536,7 @@ def _embed_scipy_dct_safe( img = Image.open(io.BytesIO(carrier_image)) width, height = img.size - flags = FLAG_COLOR_MODE if color_mode == 'color' else 0 + flags = FLAG_COLOR_MODE if color_mode == "color" else 0 # Prepare payload bits header = _create_header(len(data), flags) @@ -509,12 +551,12 @@ def _embed_scipy_dct_safe( block_order = _generate_block_order(num_blocks, seed) blocks_x = width // BLOCK_SIZE - if color_mode == 'color' and img.mode in ('RGB', 'RGBA'): - if img.mode == 'RGBA': - img = img.convert('RGB') + if color_mode == "color" and img.mode in ("RGB", "RGBA"): + if img.mode == "RGBA": + img = img.convert("RGB") # Process color image - rgb = np.array(img, dtype=np.float64, copy=True, order='C') + rgb = np.array(img, dtype=np.float64, copy=True, order="C") img.close() Y, Cb, Cr = _rgb_to_ycbcr(rgb) @@ -592,7 +634,7 @@ def _embed_in_channel_safe( h, w = channel.shape # Create result with explicit new memory - result = np.array(channel, dtype=np.float64, copy=True, order='C') + result = np.array(channel, dtype=np.float64, copy=True, order="C") bit_idx = 0 @@ -605,8 +647,10 @@ def _embed_in_channel_safe( # Extract block - create brand new array block = np.array( - result[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE], - dtype=np.float64, copy=True, order='C' + result[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE], + dtype=np.float64, + copy=True, + order="C", ) # Apply safe DCT (row-by-row) @@ -617,8 +661,7 @@ def _embed_in_channel_safe( if bit_idx >= len(bits): break dct_block[pos[0], pos[1]] = _embed_bit_in_coeff( - float(dct_block[pos[0], pos[1]]), - bits[bit_idx] + float(dct_block[pos[0], pos[1]]), bits[bit_idx] ) bit_idx += 1 @@ -626,7 +669,7 @@ def _embed_in_channel_safe( modified_block = _safe_idct2(dct_block) # Copy back - result[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE] = modified_block + result[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE] = modified_block # Clean up this iteration del block, dct_block, modified_block @@ -654,13 +697,13 @@ def _normalize_jpeg_for_jpegio(image_data: bytes) -> bytes: img = Image.open(io.BytesIO(image_data)) # Only process JPEGs - if img.format != 'JPEG': + if img.format != "JPEG": img.close() return image_data # Check quantization tables needs_normalization = False - if hasattr(img, 'quantization') and img.quantization: + if hasattr(img, "quantization") and img.quantization: for table_id, table in img.quantization.items(): # If all values in any table are <= threshold, normalize if max(table) <= JPEGIO_MAX_QUANT_VALUE_THRESHOLD: @@ -672,11 +715,11 @@ def _normalize_jpeg_for_jpegio(image_data: bytes) -> bytes: return image_data # Re-save at safe quality level - if img.mode != 'RGB': - img = img.convert('RGB') + if img.mode != "RGB": + img = img.convert("RGB") buffer = io.BytesIO() - img.save(buffer, format='JPEG', quality=JPEGIO_NORMALIZE_QUALITY, subsampling=0) + img.save(buffer, format="JPEG", quality=JPEGIO_NORMALIZE_QUALITY, subsampling=0) img.close() return buffer.getvalue() @@ -686,7 +729,7 @@ def _embed_jpegio( data: bytes, carrier_image: bytes, seed: bytes, - color_mode: str = 'color', + color_mode: str = "color", ) -> tuple[bytes, DCTEmbedStats]: """Embed using jpegio for proper JPEG coefficient modification.""" import os @@ -698,18 +741,18 @@ def _embed_jpegio( img = Image.open(io.BytesIO(carrier_image)) width, height = img.size - if img.format != 'JPEG': + if img.format != "JPEG": buffer = io.BytesIO() - if img.mode != 'RGB': - img = img.convert('RGB') - img.save(buffer, format='JPEG', quality=95, subsampling=0) + if img.mode != "RGB": + img = img.convert("RGB") + img.save(buffer, format="JPEG", quality=95, subsampling=0) carrier_image = buffer.getvalue() img.close() - input_path = _jpegio_bytes_to_file(carrier_image, suffix='.jpg') - output_path = tempfile.mktemp(suffix='.jpg') + input_path = _jpegio_bytes_to_file(carrier_image, suffix=".jpg") + output_path = tempfile.mktemp(suffix=".jpg") - flags = FLAG_COLOR_MODE if color_mode == 'color' else 0 + flags = FLAG_COLOR_MODE if color_mode == "color" else 0 try: jpeg = jio.read(input_path) @@ -750,7 +793,7 @@ def _embed_jpegio( jio.write(jpeg, output_path) - with open(output_path, 'rb') as f: + with open(output_path, "rb") as f: stego_bytes = f.read() stats = DCTEmbedStats( @@ -782,7 +825,7 @@ def extract_from_dct(stego_image: bytes, seed: bytes) -> bytes: fmt = img.format img.close() - if fmt == 'JPEG' and HAS_JPEGIO: + if fmt == "JPEG" and HAS_JPEGIO: try: return _extract_jpegio(stego_image, seed) except ValueError: @@ -798,7 +841,7 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes: width, height = img.size mode = img.mode - if mode in ('RGB', 'RGBA'): + if mode in ("RGB", "RGBA"): channel = _extract_y_channel(stego_image) else: channel = _to_grayscale(stego_image) @@ -821,8 +864,10 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes: bx = (block_num % blocks_x) * BLOCK_SIZE block = np.array( - padded[by:by+BLOCK_SIZE, bx:bx+BLOCK_SIZE], - dtype=np.float64, copy=True, order='C' + padded[by : by + BLOCK_SIZE, bx : bx + BLOCK_SIZE], + dtype=np.float64, + copy=True, + order="C", ) dct_block = _safe_dct2(block) @@ -834,7 +879,7 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes: if len(all_bits) >= HEADER_SIZE * 8: try: - _, flags, data_length = _parse_header(all_bits[:HEADER_SIZE * 8]) + _, flags, data_length = _parse_header(all_bits[: HEADER_SIZE * 8]) total_needed = (HEADER_SIZE + data_length) * 8 if len(all_bits) >= total_needed: break @@ -845,12 +890,14 @@ def _extract_scipy_dct_safe(stego_image: bytes, seed: bytes) -> bytes: gc.collect() _, flags, data_length = _parse_header(all_bits) - data_bits = all_bits[HEADER_SIZE * 8:(HEADER_SIZE + data_length) * 8] + data_bits = all_bits[HEADER_SIZE * 8 : (HEADER_SIZE + data_length) * 8] - data = bytes([ - sum(data_bits[i*8:(i+1)*8][j] << (7-j) for j in range(8)) - for i in range(data_length) - ]) + data = bytes( + [ + sum(data_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8)) + for i in range(data_length) + ] + ) return data @@ -863,7 +910,7 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes: # (shouldn't happen with stego images, but be defensive) stego_image = _normalize_jpeg_for_jpegio(stego_image) - temp_path = _jpegio_bytes_to_file(stego_image, suffix='.jpg') + temp_path = _jpegio_bytes_to_file(stego_image, suffix=".jpg") try: jpeg = jio.read(temp_path) @@ -873,15 +920,17 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes: order = _jpegio_generate_order(len(all_positions), seed) header_bits = [] - for pos_idx in order[:HEADER_SIZE * 8]: + for pos_idx in order[: HEADER_SIZE * 8]: row, col = all_positions[pos_idx] coef = coef_array[row, col] header_bits.append(coef & 1) - header_bytes = bytes([ - sum(header_bits[i*8:(i+1)*8][j] << (7-j) for j in range(8)) - for i in range(HEADER_SIZE) - ]) + header_bytes = bytes( + [ + sum(header_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8)) + for i in range(HEADER_SIZE) + ] + ) _, flags, data_length = _jpegio_parse_header(header_bytes) @@ -895,12 +944,14 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes: coef = coef_array[row, col] all_bits.append(coef & 1) - data_bits = all_bits[HEADER_SIZE * 8:] + data_bits = all_bits[HEADER_SIZE * 8 :] - data = bytes([ - sum(data_bits[i*8:(i+1)*8][j] << (7-j) for j in range(8)) - for i in range(data_length) - ]) + data = bytes( + [ + sum(data_bits[i * 8 : (i + 1) * 8][j] << (7 - j) for j in range(8)) + for i in range(data_length) + ] + ) return data @@ -915,13 +966,14 @@ def _extract_jpegio(stego_image: bytes, seed: bytes) -> bytes: # CONVENIENCE FUNCTIONS # ============================================================================ + def get_output_extension(output_format: str) -> str: if output_format == OUTPUT_FORMAT_JPEG: - return '.jpg' - return '.png' + return ".jpg" + return ".png" def get_output_mimetype(output_format: str) -> str: if output_format == OUTPUT_FORMAT_JPEG: - return 'image/jpeg' - return 'image/png' + return "image/jpeg" + return "image/png" diff --git a/src/stegasoo/debug.py b/src/stegasoo/debug.py index e18aae4..ca22bd4 100644 --- a/src/stegasoo/debug.py +++ b/src/stegasoo/debug.py @@ -68,6 +68,7 @@ def debug_exception(e: Exception, context: str = "") -> None: def time_function(func: Callable) -> Callable: """Decorator to time function execution for performance debugging.""" + @wraps(func) def wrapper(*args, **kwargs) -> Any: if not (DEBUG_ENABLED and LOG_PERFORMANCE): @@ -96,16 +97,17 @@ def memory_usage() -> dict[str, float | str]: import os import psutil + process = psutil.Process(os.getpid()) mem_info = process.memory_info() return { - 'rss_mb': mem_info.rss / 1024 / 1024, - 'vms_mb': mem_info.vms / 1024 / 1024, - 'percent': process.memory_percent(), + "rss_mb": mem_info.rss / 1024 / 1024, + "vms_mb": mem_info.vms / 1024 / 1024, + "percent": process.memory_percent(), } except ImportError: - return {'error': 'psutil not installed'} + return {"error": "psutil not installed"} def hexdump(data: bytes, offset: int = 0, length: int = 64) -> str: @@ -117,16 +119,16 @@ def hexdump(data: bytes, offset: int = 0, length: int = 64) -> str: data_to_dump = data[:length] for i in range(0, len(data_to_dump), 16): - chunk = data_to_dump[i:i+16] - hex_str = ' '.join(f'{b:02x}' for b in chunk) + chunk = data_to_dump[i : i + 16] + hex_str = " ".join(f"{b:02x}" for b in chunk) hex_str = hex_str.ljust(47) - ascii_str = ''.join(chr(b) if 32 <= b < 127 else '.' for b in chunk) + ascii_str = "".join(chr(b) if 32 <= b < 127 else "." for b in chunk) result.append(f"{offset + i:08x}: {hex_str} {ascii_str}") if len(data) > length: result.append(f"... ({len(data) - length} more bytes)") - return '\n'.join(result) + return "\n".join(result) class Debug: diff --git a/src/stegasoo/decode.py b/src/stegasoo/decode.py index 7252870..63cfa54 100644 --- a/src/stegasoo/decode.py +++ b/src/stegasoo/decode.py @@ -75,9 +75,11 @@ def decode( ... channel_key="ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456" ... ) """ - debug.print(f"decode: passphrase length={len(passphrase.split())} words, " - f"mode={embed_mode}, " - f"channel_key={'explicit' if isinstance(channel_key, str) and channel_key else 'auto' if channel_key is None else 'none'}") + debug.print( + f"decode: passphrase length={len(passphrase.split())} words, " + f"mode={embed_mode}, " + f"channel_key={'explicit' if isinstance(channel_key, str) and channel_key else 'auto' if channel_key is None else 'none'}" + ) # Validate inputs require_valid_image(stego_image, "Stego image") @@ -91,9 +93,8 @@ def decode( # Derive pixel/coefficient selection key (with channel key) from .crypto import derive_pixel_key - pixel_key = derive_pixel_key( - reference_photo, passphrase, pin, rsa_key_data, channel_key - ) + + pixel_key = derive_pixel_key(reference_photo, passphrase, pin, rsa_key_data, channel_key) # Extract encrypted data encrypted = extract_from_image( @@ -109,9 +110,7 @@ def decode( debug.print(f"Extracted {len(encrypted)} bytes from image") # Decrypt (with channel key) - result = decrypt_message( - encrypted, reference_photo, passphrase, pin, rsa_key_data, channel_key - ) + result = decrypt_message(encrypted, reference_photo, passphrase, pin, rsa_key_data, channel_key) debug.print(f"Decryption successful: {result.payload_type}") return result @@ -222,7 +221,7 @@ def decode_text( # Try to decode as text if result.file_data: try: - return result.file_data.decode('utf-8') + return result.file_data.decode("utf-8") except UnicodeDecodeError: raise DecryptionError( f"Payload is a binary file ({result.filename or 'unnamed'}), not text" diff --git a/src/stegasoo/encode.py b/src/stegasoo/encode.py index 957212d..0c87b11 100644 --- a/src/stegasoo/encode.py +++ b/src/stegasoo/encode.py @@ -82,9 +82,11 @@ def encode( ... channel_key="ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456" ... ) """ - debug.print(f"encode: passphrase length={len(passphrase.split())} words, " - f"pin={'set' if pin else 'none'}, mode={embed_mode}, " - f"channel_key={'explicit' if isinstance(channel_key, str) and channel_key else 'auto' if channel_key is None else 'none'}") + debug.print( + f"encode: passphrase length={len(passphrase.split())} words, " + f"pin={'set' if pin else 'none'}, mode={embed_mode}, " + f"channel_key={'explicit' if isinstance(channel_key, str) and channel_key else 'auto' if channel_key is None else 'none'}" + ) # Validate inputs require_valid_payload(message) @@ -105,9 +107,7 @@ def encode( debug.print(f"Encrypted payload: {len(encrypted)} bytes") # Derive pixel/coefficient selection key (with channel key) - pixel_key = derive_pixel_key( - reference_photo, passphrase, pin, rsa_key_data, channel_key - ) + pixel_key = derive_pixel_key(reference_photo, passphrase, pin, rsa_key_data, channel_key) # Embed in image stego_data, stats, extension = embed_in_image( @@ -124,7 +124,7 @@ def encode( filename = generate_filename(extension=extension) # Create result - if hasattr(stats, 'pixels_modified'): + if hasattr(stats, "pixels_modified"): # LSB mode stats return EncodeResult( stego_image=stego_data, diff --git a/src/stegasoo/exceptions.py b/src/stegasoo/exceptions.py index b001f32..60a0df2 100644 --- a/src/stegasoo/exceptions.py +++ b/src/stegasoo/exceptions.py @@ -7,6 +7,7 @@ Custom exception classes for clear error handling across all frontends. class StegasooError(Exception): """Base exception for all Stegasoo errors.""" + pass @@ -14,33 +15,40 @@ class StegasooError(Exception): # VALIDATION ERRORS # ============================================================================ + class ValidationError(StegasooError): """Base class for validation errors.""" + pass class PinValidationError(ValidationError): """PIN validation failed.""" + pass class MessageValidationError(ValidationError): """Message validation failed.""" + pass class ImageValidationError(ValidationError): """Image validation failed.""" + pass class KeyValidationError(ValidationError): """RSA key validation failed.""" + pass class SecurityFactorError(ValidationError): """Security factor requirements not met.""" + pass @@ -48,33 +56,40 @@ class SecurityFactorError(ValidationError): # CRYPTO ERRORS # ============================================================================ + class CryptoError(StegasooError): """Base class for cryptographic errors.""" + pass class EncryptionError(CryptoError): """Encryption failed.""" + pass class DecryptionError(CryptoError): """Decryption failed (wrong key, corrupted data, etc.).""" + pass class KeyDerivationError(CryptoError): """Key derivation failed.""" + pass class KeyGenerationError(CryptoError): """Key generation failed.""" + pass class KeyPasswordError(CryptoError): """RSA key password is incorrect or missing.""" + pass @@ -82,8 +97,10 @@ class KeyPasswordError(CryptoError): # STEGANOGRAPHY ERRORS # ============================================================================ + class SteganographyError(StegasooError): """Base class for steganography errors.""" + pass @@ -100,16 +117,19 @@ class CapacityError(SteganographyError): class ExtractionError(SteganographyError): """Failed to extract hidden data from image.""" + pass class EmbeddingError(SteganographyError): """Failed to embed data in image.""" + pass class InvalidHeaderError(SteganographyError): """Invalid or missing Stegasoo header in extracted data.""" + pass @@ -117,13 +137,16 @@ class InvalidHeaderError(SteganographyError): # FILE ERRORS # ============================================================================ + class FileError(StegasooError): """Base class for file-related errors.""" + pass class FileNotFoundError(FileError): """Required file not found.""" + pass diff --git a/src/stegasoo/generate.py b/src/stegasoo/generate.py index 44571de..947edcc 100644 --- a/src/stegasoo/generate.py +++ b/src/stegasoo/generate.py @@ -4,7 +4,6 @@ Stegasoo Generate Module (v3.2.0) Public API for generating credentials (PINs, passphrases, RSA keys). """ - from .constants import ( DEFAULT_PASSPHRASE_WORDS, DEFAULT_PIN_LENGTH, @@ -26,12 +25,12 @@ from .models import Credentials # Re-export from keygen for convenience __all__ = [ - 'generate_pin', - 'generate_passphrase', - 'generate_rsa_key', - 'generate_credentials', - 'export_rsa_key_pem', - 'load_rsa_key', + "generate_pin", + "generate_passphrase", + "generate_rsa_key", + "generate_credentials", + "export_rsa_key_pem", + "load_rsa_key", ] @@ -78,10 +77,7 @@ def generate_passphrase(words: int = DEFAULT_PASSPHRASE_WORDS) -> str: return generate_phrase(words) -def generate_rsa_key( - bits: int = DEFAULT_RSA_BITS, - password: str | None = None -) -> str: +def generate_rsa_key(bits: int = DEFAULT_RSA_BITS, password: str | None = None) -> str: """ Generate an RSA private key in PEM format. @@ -99,7 +95,7 @@ def generate_rsa_key( """ key_obj = _generate_rsa_key(bits) pem_bytes = export_rsa_key_pem(key_obj, password) - return pem_bytes.decode('utf-8') + return pem_bytes.decode("utf-8") def generate_credentials( @@ -140,8 +136,10 @@ def generate_credentials( 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}") + 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) @@ -154,7 +152,7 @@ def generate_credentials( 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') + rsa_key_pem = rsa_key_bytes.decode("utf-8") # Create Credentials object (v3.2.0 format) creds = Credentials( diff --git a/src/stegasoo/image_utils.py b/src/stegasoo/image_utils.py index 877b236..d695a26 100644 --- a/src/stegasoo/image_utils.py +++ b/src/stegasoo/image_utils.py @@ -43,6 +43,7 @@ def get_image_info(image_data: bytes) -> ImageInfo: 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: @@ -61,8 +62,10 @@ def get_image_info(image_data: bytes) -> ImageInfo: 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") + debug.print( + f"Image info: {width}x{height}, LSB={lsb_capacity} bytes, " + f"DCT={dct_capacity or 'N/A'} bytes" + ) return info @@ -101,6 +104,7 @@ def compare_capacity( 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 @@ -146,7 +150,7 @@ def validate_carrier_capacity( from .steganography import calculate_capacity_by_mode capacity_info = calculate_capacity_by_mode(carrier_image, embed_mode) - capacity = capacity_info['capacity_bytes'] + capacity = capacity_info["capacity_bytes"] # Add encryption overhead estimate estimated_size = payload_size + 200 # Approximate overhead @@ -156,11 +160,11 @@ def validate_carrier_capacity( 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, + "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/keygen.py b/src/stegasoo/keygen.py index 4affbc5..ba8d09a 100644 --- a/src/stegasoo/keygen.py +++ b/src/stegasoo/keygen.py @@ -50,8 +50,10 @@ def generate_pin(length: int = DEFAULT_PIN_LENGTH) -> str: >>> generate_pin(6) "812345" """ - debug.validate(MIN_PIN_LENGTH <= length <= MAX_PIN_LENGTH, - f"PIN length must be between {MIN_PIN_LENGTH} and {MAX_PIN_LENGTH}") + debug.validate( + MIN_PIN_LENGTH <= length <= MAX_PIN_LENGTH, + f"PIN length must be between {MIN_PIN_LENGTH} and {MAX_PIN_LENGTH}", + ) length = max(MIN_PIN_LENGTH, min(MAX_PIN_LENGTH, length)) @@ -59,7 +61,7 @@ def generate_pin(length: int = DEFAULT_PIN_LENGTH) -> str: first_digit = str(secrets.randbelow(9) + 1) # Remaining digits: 0-9 - rest = ''.join(str(secrets.randbelow(10)) for _ in range(length - 1)) + rest = "".join(str(secrets.randbelow(10)) for _ in range(length - 1)) pin = first_digit + rest debug.print(f"Generated PIN: {pin}") @@ -80,14 +82,16 @@ def generate_phrase(words_per_phrase: int = DEFAULT_PASSPHRASE_WORDS) -> str: >>> generate_phrase(4) "apple forest thunder mountain" """ - debug.validate(MIN_PASSPHRASE_WORDS <= words_per_phrase <= MAX_PASSPHRASE_WORDS, - f"Words per phrase must be between {MIN_PASSPHRASE_WORDS} and {MAX_PASSPHRASE_WORDS}") + debug.validate( + MIN_PASSPHRASE_WORDS <= words_per_phrase <= MAX_PASSPHRASE_WORDS, + f"Words per phrase must be between {MIN_PASSPHRASE_WORDS} and {MAX_PASSPHRASE_WORDS}", + ) words_per_phrase = max(MIN_PASSPHRASE_WORDS, min(MAX_PASSPHRASE_WORDS, words_per_phrase)) wordlist = get_wordlist() words = [secrets.choice(wordlist) for _ in range(words_per_phrase)] - phrase = ' '.join(words) + phrase = " ".join(words) debug.print(f"Generated phrase: {phrase}") return phrase @@ -114,11 +118,12 @@ def generate_day_phrases(words_per_phrase: int = DEFAULT_PASSPHRASE_WORDS) -> di {'Monday': 'apple forest thunder', 'Tuesday': 'banana river lightning', ...} """ import warnings + warnings.warn( "generate_day_phrases() is deprecated in v3.2.0. " "Use generate_phrase() for single passphrase.", DeprecationWarning, - stacklevel=2 + stacklevel=2, ) phrases = {day: generate_phrase(words_per_phrase) for day in DAY_NAMES} @@ -144,8 +149,7 @@ def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey: >>> key.key_size 2048 """ - debug.validate(bits in VALID_RSA_SIZES, - f"RSA key size must be one of {VALID_RSA_SIZES}") + debug.validate(bits in VALID_RSA_SIZES, f"RSA key size must be one of {VALID_RSA_SIZES}") if bits not in VALID_RSA_SIZES: bits = DEFAULT_RSA_BITS @@ -153,9 +157,7 @@ def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey: debug.print(f"Generating {bits}-bit RSA key...") try: key = rsa.generate_private_key( - public_exponent=65537, - key_size=bits, - backend=default_backend() + public_exponent=65537, key_size=bits, backend=default_backend() ) debug.print(f"RSA key generated: {bits} bits") return key @@ -164,10 +166,7 @@ def generate_rsa_key(bits: int = DEFAULT_RSA_BITS) -> rsa.RSAPrivateKey: raise KeyGenerationError(f"Failed to generate RSA key: {e}") from e -def export_rsa_key_pem( - private_key: rsa.RSAPrivateKey, - password: str | None = None -) -> bytes: +def export_rsa_key_pem(private_key: rsa.RSAPrivateKey, password: str | None = None) -> bytes: """ Export RSA key to PEM format. @@ -198,14 +197,11 @@ def export_rsa_key_pem( return private_key.private_bytes( encoding=serialization.Encoding.PEM, format=serialization.PrivateFormat.PKCS8, - encryption_algorithm=encryption_algorithm + encryption_algorithm=encryption_algorithm, ) -def load_rsa_key( - key_data: bytes, - password: str | None = None -) -> rsa.RSAPrivateKey: +def load_rsa_key(key_data: bytes, password: str | None = None) -> rsa.RSAPrivateKey: """ Load RSA private key from PEM data. @@ -223,8 +219,7 @@ def load_rsa_key( Example: >>> key = load_rsa_key(pem_data, "my_password") """ - debug.validate(key_data is not None and len(key_data) > 0, - "Key data cannot be empty") + debug.validate(key_data is not None and len(key_data) > 0, "Key data cannot be empty") try: pwd_bytes = password.encode() if password else None @@ -274,15 +269,11 @@ def get_key_info(key_data: bytes, password: str | None = None) -> KeyInfo: """ debug.print("Getting RSA key info") # Check if encrypted - is_encrypted = b'ENCRYPTED' in key_data + is_encrypted = b"ENCRYPTED" in key_data private_key = load_rsa_key(key_data, password) - info = KeyInfo( - key_size=private_key.key_size, - is_encrypted=is_encrypted, - pem_data=key_data - ) + info = KeyInfo(key_size=private_key.key_size, is_encrypted=is_encrypted, pem_data=key_data) debug.print(f"Key info: {info.key_size} bits, encrypted: {info.is_encrypted}") return info @@ -323,14 +314,15 @@ def generate_credentials( >>> creds.pin "812345" """ - debug.validate(use_pin or use_rsa, - "Must select at least one security factor (PIN or RSA key)") + debug.validate(use_pin or use_rsa, "Must select at least one security factor (PIN or RSA key)") 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}") + debug.print( + f"Generating credentials: PIN={use_pin}, RSA={use_rsa}, " + f"passphrase_words={passphrase_words}" + ) # Generate single passphrase (v3.2.0 - no daily rotation) passphrase = generate_phrase(passphrase_words) @@ -342,7 +334,7 @@ def generate_credentials( rsa_key_pem = None if use_rsa: rsa_key_obj = generate_rsa_key(rsa_bits) - rsa_key_pem = export_rsa_key_pem(rsa_key_obj, rsa_password).decode('utf-8') + rsa_key_pem = export_rsa_key_pem(rsa_key_obj, rsa_password).decode("utf-8") # Create Credentials object (v3.2.0 format with single passphrase) creds = Credentials( @@ -361,12 +353,13 @@ def generate_credentials( # LEGACY COMPATIBILITY # ============================================================================= + def generate_credentials_legacy( use_pin: bool = True, use_rsa: bool = False, pin_length: int = DEFAULT_PIN_LENGTH, rsa_bits: int = DEFAULT_RSA_BITS, - words_per_phrase: int = DEFAULT_PASSPHRASE_WORDS + words_per_phrase: int = DEFAULT_PASSPHRASE_WORDS, ) -> dict: """ Generate credentials in legacy format (v3.1.0 style with daily phrases). @@ -387,11 +380,12 @@ def generate_credentials_legacy( Dict with 'phrases' (dict), 'pin', 'rsa_key_pem', etc. """ import warnings + warnings.warn( "generate_credentials_legacy() returns v3.1.0 format. " "Use generate_credentials() for v3.2.0 format.", DeprecationWarning, - stacklevel=2 + stacklevel=2, ) if not use_pin and not use_rsa: @@ -405,12 +399,12 @@ def generate_credentials_legacy( rsa_key_pem = None if use_rsa: rsa_key_obj = generate_rsa_key(rsa_bits) - rsa_key_pem = export_rsa_key_pem(rsa_key_obj).decode('utf-8') + rsa_key_pem = export_rsa_key_pem(rsa_key_obj).decode("utf-8") return { - 'phrases': phrases, - 'pin': pin, - 'rsa_key_pem': rsa_key_pem, - 'rsa_bits': rsa_bits if use_rsa else None, - 'words_per_phrase': words_per_phrase, + "phrases": phrases, + "pin": pin, + "rsa_key_pem": rsa_key_pem, + "rsa_bits": rsa_bits if use_rsa else None, + "words_per_phrase": words_per_phrase, } diff --git a/src/stegasoo/models.py b/src/stegasoo/models.py index 96168d3..3ea9245 100644 --- a/src/stegasoo/models.py +++ b/src/stegasoo/models.py @@ -21,6 +21,7 @@ class Credentials: v3.2.0: Simplified to use single passphrase instead of daily rotation. """ + passphrase: str # Single passphrase (no daily rotation) pin: str | None = None rsa_key_pem: str | None = None @@ -64,6 +65,7 @@ class Credentials: @dataclass class FilePayload: """Represents a file to be embedded.""" + data: bytes filename: str mime_type: str | None = None @@ -73,7 +75,7 @@ class FilePayload: return len(self.data) @classmethod - def from_file(cls, filepath: str, filename: str | None = None) -> 'FilePayload': + def from_file(cls, filepath: str, filename: str | None = None) -> "FilePayload": """Create FilePayload from a file path.""" import mimetypes from pathlib import Path @@ -93,6 +95,7 @@ class EncodeInput: v3.2.0: Removed date_str (date no longer used in crypto). """ + message: str | bytes | FilePayload # Text, raw bytes, or file reference_photo: bytes carrier_image: bytes @@ -109,6 +112,7 @@ class EncodeResult: v3.2.0: date_used is now optional/cosmetic (not used in crypto). """ + stego_image: bytes filename: str pixels_modified: int @@ -129,6 +133,7 @@ class DecodeInput: v3.2.0: Renamed day_phrase → passphrase, no date needed. """ + stego_image: bytes reference_photo: bytes passphrase: str # Renamed from day_phrase @@ -144,6 +149,7 @@ class DecodeResult: v3.2.0: date_encoded is always None (date removed from crypto). """ + payload_type: str # 'text' or 'file' message: str | None = None # For text payloads file_data: bytes | None = None # For file payloads @@ -153,11 +159,11 @@ class DecodeResult: @property def is_file(self) -> bool: - return self.payload_type == 'file' + return self.payload_type == "file" @property def is_text(self) -> bool: - return self.payload_type == 'text' + return self.payload_type == "text" def get_content(self) -> str | bytes: """Get the decoded content (text or bytes).""" @@ -169,6 +175,7 @@ class DecodeResult: @dataclass class EmbedStats: """Statistics from image embedding.""" + pixels_modified: int total_pixels: int capacity_used: float @@ -183,6 +190,7 @@ class EmbedStats: @dataclass class KeyInfo: """Information about an RSA key.""" + key_size: int is_encrypted: bool pem_data: bytes @@ -191,13 +199,14 @@ class KeyInfo: @dataclass class ValidationResult: """Result of input validation.""" + is_valid: bool error_message: str = "" details: dict = field(default_factory=dict) warning: str | None = None # v3.2.0: Added for passphrase length warnings @classmethod - def ok(cls, warning: str | None = None, **details) -> 'ValidationResult': + def ok(cls, warning: str | None = None, **details) -> "ValidationResult": """Create a successful validation result.""" result = cls(is_valid=True, details=details) if warning: @@ -205,7 +214,7 @@ class ValidationResult: return result @classmethod - def error(cls, message: str, **details) -> 'ValidationResult': + def error(cls, message: str, **details) -> "ValidationResult": """Create a failed validation result.""" return cls(is_valid=False, error_message=message, details=details) @@ -214,9 +223,11 @@ class ValidationResult: # NEW MODELS FOR V3.2.0 PUBLIC API # ============================================================================= + @dataclass class ImageInfo: """Information about an image for steganography.""" + width: int height: int pixels: int @@ -232,6 +243,7 @@ class ImageInfo: @dataclass class CapacityComparison: """Comparison of embedding capacity between modes.""" + image_width: int image_height: int lsb_available: bool @@ -248,6 +260,7 @@ class CapacityComparison: @dataclass class GenerateResult: """Result of credential generation.""" + passphrase: str pin: str | None = None rsa_key_pem: str | None = None diff --git a/src/stegasoo/qr_utils.py b/src/stegasoo/qr_utils.py index 23cbc69..c5f69cc 100644 --- a/src/stegasoo/qr_utils.py +++ b/src/stegasoo/qr_utils.py @@ -20,6 +20,7 @@ from PIL import Image try: import qrcode from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_M + HAS_QRCODE_WRITE = True except ImportError: HAS_QRCODE_WRITE = False @@ -28,6 +29,7 @@ except ImportError: try: from pyzbar.pyzbar import ZBarSymbol from pyzbar.pyzbar import decode as pyzbar_decode + HAS_QRCODE_READ = True except ImportError: HAS_QRCODE_READ = False @@ -53,8 +55,8 @@ def compress_data(data: str) -> str: Returns: Compressed string with STEGASOO-Z: prefix """ - compressed = zlib.compress(data.encode('utf-8'), level=9) - encoded = base64.b64encode(compressed).decode('ascii') + compressed = zlib.compress(data.encode("utf-8"), level=9) + encoded = base64.b64encode(compressed).decode("ascii") return COMPRESSION_PREFIX + encoded @@ -74,9 +76,9 @@ def decompress_data(data: str) -> str: if not data.startswith(COMPRESSION_PREFIX): raise ValueError("Data is not in compressed format") - encoded = data[len(COMPRESSION_PREFIX):] + encoded = data[len(COMPRESSION_PREFIX) :] compressed = base64.b64decode(encoded) - return zlib.decompress(compressed).decode('utf-8') + return zlib.decompress(compressed).decode("utf-8") def normalize_pem(pem_data: str) -> str: @@ -101,25 +103,25 @@ def normalize_pem(pem_data: str) -> str: import re # Step 1: Normalize ALL line endings to \n - pem_data = pem_data.replace('\r\n', '\n').replace('\r', '\n') + pem_data = pem_data.replace("\r\n", "\n").replace("\r", "\n") # Step 2: Remove leading/trailing whitespace pem_data = pem_data.strip() # Step 3: Remove any non-ASCII characters (QR artifacts) - pem_data = ''.join(char for char in pem_data if ord(char) < 128) + pem_data = "".join(char for char in pem_data if ord(char) < 128) # Step 4: Extract header, content, and footer with flexible regex # This handles variations like: # - "PRIVATE KEY" vs "RSA PRIVATE KEY" # - Extra spaces in headers # - Missing spaces - pattern = r'(-----BEGIN[^-]*-----)(.*?)(-----END[^-]*-----)' + pattern = r"(-----BEGIN[^-]*-----)(.*?)(-----END[^-]*-----)" match = re.search(pattern, pem_data, re.DOTALL | re.IGNORECASE) if not match: # Fallback: try even more permissive pattern - pattern = r'(-+BEGIN[^-]+-+)(.*?)(-+END[^-]+-+)' + pattern = r"(-+BEGIN[^-]+-+)(.*?)(-+END[^-]+-+)" match = re.search(pattern, pem_data, re.DOTALL | re.IGNORECASE) if not match: @@ -132,38 +134,35 @@ def normalize_pem(pem_data: str) -> str: # Step 5: Normalize header and footer # Standardize spacing and ensure proper format - header = re.sub(r'\s+', ' ', header_raw) - footer = re.sub(r'\s+', ' ', footer_raw) + header = re.sub(r"\s+", " ", header_raw) + footer = re.sub(r"\s+", " ", footer_raw) # Ensure exactly 5 dashes on each side - header = re.sub(r'^-+', '-----', header) - header = re.sub(r'-+$', '-----', header) - footer = re.sub(r'^-+', '-----', footer) - footer = re.sub(r'-+$', '-----', footer) + header = re.sub(r"^-+", "-----", header) + header = re.sub(r"-+$", "-----", header) + footer = re.sub(r"^-+", "-----", footer) + footer = re.sub(r"-+$", "-----", footer) # Step 6: Clean the base64 content THOROUGHLY # Remove ALL whitespace: spaces, tabs, newlines # Keep only valid base64 characters: A-Z, a-z, 0-9, +, /, = - content_clean = ''.join( - char for char in content_raw - if char.isalnum() or char in '+/=' - ) + content_clean = "".join(char for char in content_raw if char.isalnum() or char in "+/=") # Double-check: remove any remaining invalid characters - content_clean = re.sub(r'[^A-Za-z0-9+/=]', '', content_clean) + content_clean = re.sub(r"[^A-Za-z0-9+/=]", "", content_clean) # Step 7: Fix base64 padding # Base64 strings must be divisible by 4 remainder = len(content_clean) % 4 if remainder: - content_clean += '=' * (4 - remainder) + content_clean += "=" * (4 - remainder) # Step 8: Split into 64-character lines (PEM standard) - lines = [content_clean[i:i+64] for i in range(0, len(content_clean), 64)] + lines = [content_clean[i : i + 64] for i in range(0, len(content_clean), 64)] # Step 9: Reconstruct with EXACT PEM formatting # Format: header\ncontent_line1\ncontent_line2\n...\nfooter\n - return header + '\n' + '\n'.join(lines) + '\n' + footer + '\n' + return header + "\n" + "\n".join(lines) + "\n" + footer + "\n" def is_compressed(data: str) -> bool: @@ -205,7 +204,7 @@ def can_fit_in_qr(data: str, compress: bool = False) -> bool: if compress: size = get_compressed_size(data) else: - size = len(data.encode('utf-8')) + size = len(data.encode("utf-8")) return size <= QR_MAX_BINARY @@ -214,11 +213,7 @@ def needs_compression(data: str) -> bool: return not can_fit_in_qr(data, compress=False) and can_fit_in_qr(data, compress=True) -def generate_qr_code( - data: str, - compress: bool = False, - error_correction=None -) -> bytes: +def generate_qr_code(data: str, compress: bool = False, error_correction=None) -> bytes: """ Generate a QR code PNG from string data. @@ -244,10 +239,9 @@ def generate_qr_code( qr_data = compress_data(data) # Check size - if len(qr_data.encode('utf-8')) > QR_MAX_BINARY: + if len(qr_data.encode("utf-8")) > QR_MAX_BINARY: raise ValueError( - f"Data too large for QR code ({len(qr_data)} bytes). " - f"Maximum: {QR_MAX_BINARY} bytes" + f"Data too large for QR code ({len(qr_data)} bytes). " f"Maximum: {QR_MAX_BINARY} bytes" ) # Use lower error correction for larger data @@ -266,7 +260,7 @@ def generate_qr_code( img = qr.make_image(fill_color="black", back_color="white") buf = io.BytesIO() - img.save(buf, format='PNG') + img.save(buf, format="PNG") buf.seek(0) return buf.getvalue() @@ -294,8 +288,8 @@ def read_qr_code(image_data: bytes) -> str | None: img: Image.Image = Image.open(io.BytesIO(image_data)) # Convert to RGB if necessary (pyzbar works best with RGB/grayscale) - if img.mode not in ('RGB', 'L'): - img = img.convert('RGB') + if img.mode not in ("RGB", "L"): + img = img.convert("RGB") # Decode QR codes decoded = pyzbar_decode(img, symbols=[ZBarSymbol.QRCODE]) @@ -304,7 +298,7 @@ def read_qr_code(image_data: bytes) -> str | None: return None # Return first QR code found - result: str = decoded[0].data.decode('utf-8') + result: str = decoded[0].data.decode("utf-8") return result except Exception: @@ -321,7 +315,7 @@ def read_qr_code_from_file(filepath: str) -> str | None: Returns: Decoded string, or None if no QR code found """ - with open(filepath, 'rb') as f: + with open(filepath, "rb") as f: return read_qr_code(f.read()) @@ -355,7 +349,7 @@ def extract_key_from_qr(image_data: bytes) -> str | None: key_pem = qr_data # Step 3: Validate it looks like a PEM key - if '-----BEGIN' not in key_pem or '-----END' not in key_pem: + if "-----BEGIN" not in key_pem or "-----END" not in key_pem: return None # Step 4: Aggressively normalize PEM format @@ -367,7 +361,7 @@ def extract_key_from_qr(image_data: bytes) -> str | None: return None # Step 5: Final validation - ensure it still looks like PEM - if '-----BEGIN' in key_pem and '-----END' in key_pem: + if "-----BEGIN" in key_pem and "-----END" in key_pem: return key_pem return None @@ -383,14 +377,14 @@ def extract_key_from_qr_file(filepath: str) -> str | None: Returns: PEM-encoded RSA key string, or None if not found/invalid """ - with open(filepath, 'rb') as f: + with open(filepath, "rb") as f: return extract_key_from_qr(f.read()) def detect_and_crop_qr( image_data: bytes, padding_percent: float = QR_CROP_PADDING_PERCENT, - min_padding_px: int = QR_CROP_MIN_PADDING_PX + min_padding_px: int = QR_CROP_MIN_PADDING_PX, ) -> bytes | None: """ Detect QR code in image and crop to it, handling rotation. @@ -420,8 +414,8 @@ def detect_and_crop_qr( original_mode = img.mode # Convert for pyzbar detection - if img.mode not in ('RGB', 'L'): - detect_img = img.convert('RGB') + if img.mode not in ("RGB", "L"): + detect_img = img.convert("RGB") else: detect_img = img @@ -468,16 +462,17 @@ def detect_and_crop_qr( # Convert to PNG bytes buf = io.BytesIO() # Preserve transparency if present - if original_mode in ('RGBA', 'LA', 'P'): - cropped.save(buf, format='PNG') + if original_mode in ("RGBA", "LA", "P"): + cropped.save(buf, format="PNG") else: - cropped.save(buf, format='PNG') + cropped.save(buf, format="PNG") buf.seek(0) return buf.getvalue() except Exception as e: # Log for debugging but return None for clean API import sys + print(f"QR crop error: {e}", file=sys.stderr) return None @@ -485,7 +480,7 @@ def detect_and_crop_qr( def detect_and_crop_qr_file( filepath: str, padding_percent: float = QR_CROP_PADDING_PERCENT, - min_padding_px: int = QR_CROP_MIN_PADDING_PX + min_padding_px: int = QR_CROP_MIN_PADDING_PX, ) -> bytes | None: """ Detect QR code in image file and crop to it. @@ -498,7 +493,7 @@ def detect_and_crop_qr_file( Returns: Cropped PNG image bytes, or None if no QR code found """ - with open(filepath, 'rb') as f: + with open(filepath, "rb") as f: return detect_and_crop_qr(f.read(), padding_percent, min_padding_px) diff --git a/src/stegasoo/steganography.py b/src/stegasoo/steganography.py index 8681159..c12ac08 100644 --- a/src/stegasoo/steganography.py +++ b/src/stegasoo/steganography.py @@ -40,21 +40,21 @@ from .exceptions import CapacityError, EmbeddingError from .models import EmbedStats, FilePayload # Lossless formats that preserve LSB data -LOSSLESS_FORMATS = {'PNG', 'BMP', 'TIFF'} +LOSSLESS_FORMATS = {"PNG", "BMP", "TIFF"} # Format to extension mapping FORMAT_TO_EXT = { - 'PNG': 'png', - 'BMP': 'bmp', - 'TIFF': 'tiff', + "PNG": "png", + "BMP": "bmp", + "TIFF": "tiff", } # Extension to PIL format mapping EXT_TO_FORMAT = { - 'png': 'PNG', - 'bmp': 'BMP', - 'tiff': 'TIFF', - 'tif': 'TIFF', + "png": "PNG", + "bmp": "BMP", + "tiff": "TIFF", + "tif": "TIFF", } # ============================================================================= @@ -73,17 +73,17 @@ EXT_TO_FORMAT = { # v3.2.0 had 65 bytes (no flags byte) # v3.1.0 had date field (10 bytes + 1 byte length) = 76 bytes header -HEADER_OVERHEAD = 66 # v4.0.0: Magic + version + flags + salt + iv + tag -LENGTH_PREFIX = 4 # 4 bytes for payload length in LSB embedding +HEADER_OVERHEAD = 66 # v4.0.0: Magic + version + flags + salt + iv + tag +LENGTH_PREFIX = 4 # 4 bytes for payload length in LSB embedding ENCRYPTION_OVERHEAD = HEADER_OVERHEAD + LENGTH_PREFIX # 70 bytes total # DCT output format options (v3.0.1) -DCT_OUTPUT_PNG = 'png' -DCT_OUTPUT_JPEG = 'jpeg' +DCT_OUTPUT_PNG = "png" +DCT_OUTPUT_JPEG = "jpeg" # DCT color mode options (v3.0.1) -DCT_COLOR_GRAYSCALE = 'grayscale' -DCT_COLOR_COLOR = 'color' +DCT_COLOR_GRAYSCALE = "grayscale" +DCT_COLOR_COLOR = "color" # ============================================================================= @@ -98,6 +98,7 @@ def _get_dct_module(): global _dct_module if _dct_module is None: from . import dct_steganography + _dct_module = dct_steganography return _dct_module @@ -124,6 +125,7 @@ def has_dct_support() -> bool: # FORMAT UTILITIES # ============================================================================= + def get_output_format(input_format: str | None) -> tuple[str, str]: """ Determine the output format based on input format. @@ -135,23 +137,25 @@ def get_output_format(input_format: str | None) -> tuple[str, str]: Tuple of (PIL format string, file extension) for output Falls back to PNG for lossy or unknown formats. """ - debug.validate(input_format is None or isinstance(input_format, str), - "Input format must be string or None") + debug.validate( + input_format is None or isinstance(input_format, str), "Input format must be string or None" + ) if input_format and input_format.upper() in LOSSLESS_FORMATS: fmt = input_format.upper() - ext = FORMAT_TO_EXT.get(fmt, 'png') + ext = FORMAT_TO_EXT.get(fmt, "png") debug.print(f"Using lossless format: {fmt} -> .{ext}") return fmt, ext debug.print(f"Input format {input_format} is lossy or unknown, defaulting to PNG") - return 'PNG', 'png' + return "PNG", "png" # ============================================================================= # CAPACITY FUNCTIONS # ============================================================================= + def will_fit( payload: str | bytes | FilePayload | int, carrier_image: bytes, @@ -175,12 +179,12 @@ def will_fit( payload_size = payload payload_data = None elif isinstance(payload, str): - payload_data = payload.encode('utf-8') + payload_data = payload.encode("utf-8") payload_size = len(payload_data) elif isinstance(payload, FilePayload): payload_data = payload.data - filename_overhead = len(payload.filename.encode('utf-8')) if payload.filename else 0 - mime_overhead = len(payload.mime_type.encode('utf-8')) if payload.mime_type else 0 + filename_overhead = len(payload.filename.encode("utf-8")) if payload.filename else 0 + mime_overhead = len(payload.mime_type.encode("utf-8")) if payload.mime_type else 0 payload_size = len(payload.data) + filename_overhead + mime_overhead + 5 else: payload_data = payload @@ -198,6 +202,7 @@ def will_fit( if include_compression_estimate and payload_data is not None and len(payload_data) >= 64: try: import zlib + compressed = zlib.compress(payload_data, level=6) compressed_size = len(compressed) + 9 # Compression header if compressed_size < payload_size: @@ -211,14 +216,14 @@ def will_fit( usage_percent = (estimated_encrypted_size / capacity * 100) if capacity > 0 else 100.0 return { - 'fits': fits, - 'payload_size': payload_size, - 'estimated_encrypted_size': estimated_encrypted_size, - 'capacity': capacity, - 'usage_percent': min(usage_percent, 100.0), - 'headroom': headroom, - 'compressed_estimate': compressed_estimate, - 'mode': EMBED_MODE_LSB, + "fits": fits, + "payload_size": payload_size, + "estimated_encrypted_size": estimated_encrypted_size, + "capacity": capacity, + "usage_percent": min(usage_percent, 100.0), + "headroom": headroom, + "compressed_estimate": compressed_estimate, + "mode": EMBED_MODE_LSB, } @@ -233,8 +238,9 @@ def calculate_capacity(image_data: bytes, bits_per_channel: int = 1) -> int: Returns: Maximum bytes that can be embedded (minus overhead) """ - debug.validate(bits_per_channel in (1, 2), - f"bits_per_channel must be 1 or 2, got {bits_per_channel}") + debug.validate( + bits_per_channel in (1, 2), f"bits_per_channel must be 1 or 2, got {bits_per_channel}" + ) img_file = Image.open(io.BytesIO(image_data)) try: @@ -273,12 +279,12 @@ def calculate_capacity_by_mode( dct_info = dct_mod.calculate_dct_capacity(image_data) return { - 'mode': EMBED_MODE_DCT, - 'capacity_bytes': dct_info.usable_capacity_bytes, - 'capacity_bits': dct_info.total_capacity_bits, - 'width': dct_info.width, - 'height': dct_info.height, - 'total_blocks': dct_info.total_blocks, + "mode": EMBED_MODE_DCT, + "capacity_bytes": dct_info.usable_capacity_bytes, + "capacity_bits": dct_info.total_capacity_bits, + "width": dct_info.width, + "height": dct_info.height, + "total_blocks": dct_info.total_blocks, } else: capacity = calculate_capacity(image_data, bits_per_channel) @@ -289,12 +295,12 @@ def calculate_capacity_by_mode( img.close() return { - 'mode': EMBED_MODE_LSB, - 'capacity_bytes': capacity, - 'capacity_bits': capacity * 8, - 'width': width, - 'height': height, - 'bits_per_channel': bits_per_channel, + "mode": EMBED_MODE_LSB, + "capacity_bytes": capacity, + "capacity_bits": capacity * 8, + "width": width, + "height": height, + "bits_per_channel": bits_per_channel, } @@ -318,13 +324,13 @@ def will_fit_by_mode( """ if embed_mode == EMBED_MODE_DCT: if not has_dct_support(): - return {'fits': False, 'error': 'scipy not available', 'mode': EMBED_MODE_DCT} + return {"fits": False, "error": "scipy not available", "mode": EMBED_MODE_DCT} if isinstance(payload, int): payload_size = payload elif isinstance(payload, str): - payload_size = len(payload.encode('utf-8')) - elif hasattr(payload, 'data'): + payload_size = len(payload.encode("utf-8")) + elif hasattr(payload, "data"): payload_size = len(payload.data) else: payload_size = len(payload) @@ -339,12 +345,12 @@ def will_fit_by_mode( usage_percent = (estimated_size / capacity * 100) if capacity > 0 else 100.0 return { - 'fits': fits, - 'payload_size': payload_size, - 'capacity': capacity, - 'usage_percent': min(usage_percent, 100.0), - 'headroom': capacity - estimated_size, - 'mode': EMBED_MODE_DCT, + "fits": fits, + "payload_size": payload_size, + "capacity": capacity, + "usage_percent": min(usage_percent, 100.0), + "headroom": capacity - estimated_size, + "mode": EMBED_MODE_DCT, } else: return will_fit(payload, carrier_image, bits_per_channel) @@ -359,17 +365,17 @@ def get_available_modes() -> dict: """ return { EMBED_MODE_LSB: { - 'available': True, - 'name': 'Spatial LSB', - 'description': 'Embed in pixel LSBs, outputs PNG/BMP', - 'output_format': 'PNG (color)', + "available": True, + "name": "Spatial LSB", + "description": "Embed in pixel LSBs, outputs PNG/BMP", + "output_format": "PNG (color)", }, EMBED_MODE_DCT: { - 'available': has_dct_support(), - 'name': 'DCT Domain', - 'description': 'Embed in DCT coefficients, outputs grayscale PNG or JPEG', - 'output_formats': ['PNG (grayscale)', 'JPEG (grayscale)'], - 'requires': 'scipy', + "available": has_dct_support(), + "name": "DCT Domain", + "description": "Embed in DCT coefficients, outputs grayscale PNG or JPEG", + "output_formats": ["PNG (grayscale)", "JPEG (grayscale)"], + "requires": "scipy", }, } @@ -403,20 +409,20 @@ def compare_modes(image_data: bytes) -> dict: dct_available = False return { - 'width': width, - 'height': height, - 'lsb': { - 'capacity_bytes': lsb_bytes, - 'capacity_kb': lsb_bytes / 1024, - 'available': True, - 'output': 'PNG (color)', + "width": width, + "height": height, + "lsb": { + "capacity_bytes": lsb_bytes, + "capacity_kb": lsb_bytes / 1024, + "available": True, + "output": "PNG (color)", }, - 'dct': { - 'capacity_bytes': dct_bytes, - 'capacity_kb': dct_bytes / 1024, - 'available': dct_available, - 'output': 'PNG or JPEG (grayscale)', - 'ratio_vs_lsb': (dct_bytes / lsb_bytes * 100) if lsb_bytes > 0 else 0, + "dct": { + "capacity_bytes": dct_bytes, + "capacity_kb": dct_bytes / 1024, + "available": dct_available, + "output": "PNG or JPEG (grayscale)", + "ratio_vs_lsb": (dct_bytes / lsb_bytes * 100) if lsb_bytes > 0 else 0, }, } @@ -425,6 +431,7 @@ def compare_modes(image_data: bytes) -> dict: # PIXEL INDEX GENERATION # ============================================================================= + @debug.time def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list[int]: """ @@ -436,23 +443,24 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list debug.validate(len(key) == 32, f"Pixel key must be 32 bytes, got {len(key)}") debug.validate(num_pixels > 0, f"Number of pixels must be positive, got {num_pixels}") debug.validate(num_needed > 0, f"Number needed must be positive, got {num_needed}") - debug.validate(num_needed <= num_pixels, - f"Cannot select {num_needed} pixels from {num_pixels} available") + debug.validate( + num_needed <= num_pixels, f"Cannot select {num_needed} pixels from {num_pixels} available" + ) debug.print(f"Generating {num_needed} pixel indices from {num_pixels} total pixels") if num_needed >= num_pixels // 2: debug.print(f"Using full shuffle (needed {num_needed}/{num_pixels} pixels)") - nonce = b'\x00' * 16 + nonce = b"\x00" * 16 cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend()) encryptor = cipher.encryptor() indices = list(range(num_pixels)) - random_bytes = encryptor.update(b'\x00' * (num_pixels * 4)) + random_bytes = encryptor.update(b"\x00" * (num_pixels * 4)) for i in range(num_pixels - 1, 0, -1): - j_bytes = random_bytes[(num_pixels - 1 - i) * 4:(num_pixels - i) * 4] - j = int.from_bytes(j_bytes, 'big') % (i + 1) + j_bytes = random_bytes[(num_pixels - 1 - i) * 4 : (num_pixels - i) * 4] + j = int.from_bytes(j_bytes, "big") % (i + 1) indices[i], indices[j] = indices[j], indices[i] selected = indices[:num_needed] @@ -463,17 +471,17 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list selected = [] used = set() - nonce = b'\x00' * 16 + nonce = b"\x00" * 16 cipher = Cipher(algorithms.ChaCha20(key, nonce), mode=None, backend=default_backend()) encryptor = cipher.encryptor() bytes_needed = (num_needed * 2) * 4 - random_bytes = encryptor.update(b'\x00' * bytes_needed) + random_bytes = encryptor.update(b"\x00" * bytes_needed) byte_offset = 0 collisions = 0 while len(selected) < num_needed and byte_offset < len(random_bytes) - 4: - idx = int.from_bytes(random_bytes[byte_offset:byte_offset + 4], 'big') % num_pixels + idx = int.from_bytes(random_bytes[byte_offset : byte_offset + 4], "big") % num_pixels byte_offset += 4 if idx not in used: @@ -486,8 +494,8 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list debug.print(f"Need {num_needed - len(selected)} more indices, generating...") extra_needed = num_needed - len(selected) for _ in range(extra_needed * 2): - extra_bytes = encryptor.update(b'\x00' * 4) - idx = int.from_bytes(extra_bytes, 'big') % num_pixels + extra_bytes = encryptor.update(b"\x00" * 4) + idx = int.from_bytes(extra_bytes, "big") % num_pixels if idx not in used: used.add(idx) selected.append(idx) @@ -495,8 +503,10 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list break debug.print(f"Generated {len(selected)} indices with {collisions} collisions") - debug.validate(len(selected) == num_needed, - f"Failed to generate enough indices: {len(selected)}/{num_needed}") + debug.validate( + len(selected) == num_needed, + f"Failed to generate enough indices: {len(selected)}/{num_needed}", + ) return selected @@ -504,6 +514,7 @@ def generate_pixel_indices(key: bytes, num_pixels: int, num_needed: int) -> list # EMBEDDING FUNCTIONS # ============================================================================= + @debug.time def embed_in_image( data: bytes, @@ -513,8 +524,8 @@ def embed_in_image( output_format: str | None = None, embed_mode: str = EMBED_MODE_LSB, dct_output_format: str = DCT_OUTPUT_PNG, - dct_color_mode: str = 'grayscale', -) -> tuple[bytes, Union[EmbedStats, 'DCTEmbedStats'], str]: + dct_color_mode: str = "grayscale", +) -> tuple[bytes, Union[EmbedStats, "DCTEmbedStats"], str]: """ Embed data into an image using specified mode. @@ -537,15 +548,15 @@ def embed_in_image( ImportError: If DCT mode requested but scipy unavailable """ debug.print(f"embed_in_image: mode={embed_mode}, data={len(data)} bytes") - debug.validate(embed_mode in VALID_EMBED_MODES, - f"Invalid embed_mode: {embed_mode}. Use 'lsb' or 'dct'") + debug.validate( + embed_mode in VALID_EMBED_MODES, f"Invalid embed_mode: {embed_mode}. Use 'lsb' or 'dct'" + ) # DCT MODE if embed_mode == EMBED_MODE_DCT: if not has_dct_support(): raise ImportError( - "scipy is required for DCT embedding mode. " - "Install with: pip install scipy" + "scipy is required for DCT embedding mode. " "Install with: pip install scipy" ) # Validate DCT output format @@ -554,9 +565,9 @@ def embed_in_image( dct_output_format = DCT_OUTPUT_PNG # Validate DCT color mode (v3.0.1) - if dct_color_mode not in ('grayscale', 'color'): + 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' + dct_color_mode = "grayscale" dct_mod = _get_dct_module() @@ -571,12 +582,14 @@ def embed_in_image( # Determine extension based on output format if dct_output_format == DCT_OUTPUT_JPEG: - ext = 'jpg' + ext = "jpg" else: - ext = 'png' + ext = "png" - debug.print(f"DCT embedding complete: {dct_output_format.upper()} output, " - f"color_mode={dct_color_mode}, ext={ext}") + debug.print( + f"DCT embedding complete: {dct_output_format.upper()} output, " + f"color_mode={dct_color_mode}, ext={ext}" + ) return stego_bytes, dct_stats, ext # LSB MODE @@ -595,10 +608,10 @@ def _embed_lsb( """ debug.print(f"LSB embedding {len(data)} bytes into image") debug.data(pixel_key, "Pixel key for embedding") - debug.validate(bits_per_channel in (1, 2), - f"bits_per_channel must be 1 or 2, got {bits_per_channel}") - debug.validate(len(pixel_key) == 32, - f"Pixel key must be 32 bytes, got {len(pixel_key)}") + debug.validate( + bits_per_channel in (1, 2), f"bits_per_channel must be 1 or 2, got {bits_per_channel}" + ) + debug.validate(len(pixel_key) == 32, f"Pixel key must be 32 bytes, got {len(pixel_key)}") img_file = None img = None @@ -610,8 +623,8 @@ def _embed_lsb( debug.print(f"Carrier image: {img_file.size[0]}x{img_file.size[1]}, format: {input_format}") - img = img_file.convert('RGB') if img_file.mode != 'RGB' else img_file.copy() - if img_file.mode != 'RGB': + img = img_file.convert("RGB") if img_file.mode != "RGB" else img_file.copy() + if img_file.mode != "RGB": debug.print(f"Converting image from {img_file.mode} to RGB") pixels = list(img.getdata()) @@ -622,16 +635,18 @@ def _embed_lsb( debug.print(f"Image capacity: {max_bytes} bytes at {bits_per_channel} bit(s)/channel") - data_with_len = struct.pack('>I', len(data)) + data + data_with_len = struct.pack(">I", len(data)) + data if len(data_with_len) > max_bytes: debug.print(f"Capacity error: need {len(data_with_len)}, have {max_bytes}") raise CapacityError(len(data_with_len), max_bytes) - debug.print(f"Total data to embed: {len(data_with_len)} bytes " - f"({len(data_with_len)/max_bytes*100:.1f}% of capacity)") + debug.print( + f"Total data to embed: {len(data_with_len)} bytes " + f"({len(data_with_len)/max_bytes*100:.1f}% of capacity)" + ) - binary_data = ''.join(format(b, '08b') for b in data_with_len) + binary_data = "".join(format(b, "08b") for b in data_with_len) pixels_needed = (len(binary_data) + bits_per_pixel - 1) // bits_per_pixel debug.print(f"Need {pixels_needed} pixels to embed {len(binary_data)} bits") @@ -654,7 +669,9 @@ def _embed_lsb( for channel_idx, channel_val in enumerate([r, g, b]): if bit_idx >= len(binary_data): break - bits = binary_data[bit_idx:bit_idx + bits_per_channel].ljust(bits_per_channel, '0') + bits = binary_data[bit_idx : bit_idx + bits_per_channel].ljust( + bits_per_channel, "0" + ) new_val = (channel_val & clear_mask) | int(bits, 2) if channel_val != new_val: @@ -674,12 +691,12 @@ def _embed_lsb( debug.print(f"Modified {modified_pixels} pixels (out of {len(selected_indices)} selected)") - stego_img = Image.new('RGB', img.size) + stego_img = Image.new("RGB", img.size) stego_img.putdata(new_pixels) if output_format: out_fmt = output_format.upper() - out_ext = FORMAT_TO_EXT.get(out_fmt, 'png') + out_ext = FORMAT_TO_EXT.get(out_fmt, "png") debug.print(f"Using forced output format: {out_fmt}") else: out_fmt, out_ext = get_output_format(input_format) @@ -693,7 +710,7 @@ def _embed_lsb( pixels_modified=modified_pixels, total_pixels=num_pixels, capacity_used=len(data_with_len) / max_bytes, - bytes_embedded=len(data_with_len) + bytes_embedded=len(data_with_len), ) debug.print(f"LSB embedding complete: {out_fmt} image, {len(output.getvalue())} bytes") @@ -718,6 +735,7 @@ def _embed_lsb( # EXTRACTION FUNCTIONS # ============================================================================= + @debug.time def extract_from_image( image_data: bytes, @@ -777,18 +795,15 @@ def _extract_dct(image_data: bytes, pixel_key: bytes) -> bytes | None: return None -def _extract_lsb( - image_data: bytes, - pixel_key: bytes, - bits_per_channel: int = 1 -) -> bytes | None: +def _extract_lsb(image_data: bytes, pixel_key: bytes, bits_per_channel: int = 1) -> bytes | None: """ Extract using LSB mode (internal implementation). """ debug.print(f"LSB extracting from {len(image_data)} byte image") debug.data(pixel_key, "Pixel key for extraction") - debug.validate(bits_per_channel in (1, 2), - f"bits_per_channel must be 1 or 2, got {bits_per_channel}") + debug.validate( + bits_per_channel in (1, 2), f"bits_per_channel must be 1 or 2, got {bits_per_channel}" + ) img_file = None img = None @@ -797,8 +812,8 @@ def _extract_lsb( img_file = Image.open(io.BytesIO(image_data)) debug.print(f"Image: {img_file.size[0]}x{img_file.size[1]}, format: {img_file.format}") - img = img_file.convert('RGB') if img_file.mode != 'RGB' else img_file.copy() - if img_file.mode != 'RGB': + img = img_file.convert("RGB") if img_file.mode != "RGB" else img_file.copy() + if img_file.mode != "RGB": debug.print(f"Converting image from {img_file.mode} to RGB") pixels = list(img.getdata()) @@ -812,7 +827,7 @@ def _extract_lsb( initial_indices = generate_pixel_indices(pixel_key, num_pixels, initial_pixels) - binary_data = '' + binary_data = "" for pixel_idx in initial_indices: r, g, b = pixels[pixel_idx] for channel in [r, g, b]: @@ -825,7 +840,7 @@ def _extract_lsb( debug.print(f"Not enough bits for length: {len(length_bits)}/32") return None - data_length = struct.unpack('>I', int(length_bits, 2).to_bytes(4, 'big'))[0] + data_length = struct.unpack(">I", int(length_bits, 2).to_bytes(4, "big"))[0] debug.print(f"Extracted length: {data_length} bytes") except Exception as e: debug.print(f"Failed to parse length: {e}") @@ -843,14 +858,14 @@ def _extract_lsb( selected_indices = generate_pixel_indices(pixel_key, num_pixels, pixels_needed) - binary_data = '' + binary_data = "" for pixel_idx in selected_indices: r, g, b = pixels[pixel_idx] for channel in [r, g, b]: for bit_pos in range(bits_per_channel - 1, -1, -1): binary_data += str((channel >> bit_pos) & 1) - data_bits = binary_data[32:32 + (data_length * 8)] + data_bits = binary_data[32 : 32 + (data_length * 8)] if len(data_bits) < data_length * 8: debug.print(f"Insufficient bits: {len(data_bits)} < {data_length * 8}") @@ -858,7 +873,7 @@ def _extract_lsb( data_bytes = bytearray() for i in range(0, len(data_bits), 8): - byte_bits = data_bits[i:i + 8] + byte_bits = data_bits[i : i + 8] if len(byte_bits) == 8: data_bytes.append(int(byte_bits, 2)) @@ -880,6 +895,7 @@ def _extract_lsb( # UTILITY FUNCTIONS # ============================================================================= + def get_image_dimensions(image_data: bytes) -> tuple[int, int]: """Get image dimensions without loading full image.""" debug.validate(len(image_data) > 0, "Image data cannot be empty") diff --git a/src/stegasoo/utils.py b/src/stegasoo/utils.py index 6129776..9e99c1f 100644 --- a/src/stegasoo/utils.py +++ b/src/stegasoo/utils.py @@ -18,7 +18,7 @@ from .constants import DAY_NAMES from .debug import debug -def strip_image_metadata(image_data: bytes, output_format: str = 'PNG') -> bytes: +def strip_image_metadata(image_data: bytes, output_format: str = "PNG") -> bytes: """ Remove all metadata (EXIF, ICC profiles, etc.) from an image. @@ -41,8 +41,8 @@ def strip_image_metadata(image_data: bytes, output_format: str = 'PNG') -> bytes img = Image.open(io.BytesIO(image_data)) # Convert to RGB if needed (handles RGBA, P, L, etc.) - if img.mode not in ('RGB', 'RGBA'): - img = img.convert('RGB') + if img.mode not in ("RGB", "RGBA"): + img = img.convert("RGB") # Create fresh image - this discards all metadata clean = Image.new(img.mode, img.size) @@ -56,11 +56,7 @@ def strip_image_metadata(image_data: bytes, output_format: str = 'PNG') -> bytes return output.getvalue() -def generate_filename( - date_str: str | None = None, - prefix: str = "", - extension: str = "png" -) -> str: +def generate_filename(date_str: str | None = None, prefix: str = "", extension: str = "png") -> str: """ Generate a filename for stego images. @@ -78,17 +74,19 @@ def generate_filename( >>> generate_filename("2023-12-25", "secret_", "png") "secret_a1b2c3d4_20231225.png" """ - debug.validate(bool(extension) and '.' not in extension, - f"Extension must not contain dot, got '{extension}'") + debug.validate( + bool(extension) and "." not in extension, + f"Extension must not contain dot, got '{extension}'", + ) if date_str is None: date_str = date.today().isoformat() - date_compact = date_str.replace('-', '') + date_compact = date_str.replace("-", "") random_hex = secrets.token_hex(4) # Ensure extension doesn't have a leading dot - extension = extension.lstrip('.') + extension = extension.lstrip(".") filename = f"{prefix}{random_hex}_{date_compact}.{extension}" debug.print(f"Generated filename: {filename}") @@ -114,7 +112,7 @@ def parse_date_from_filename(filename: str) -> str | None: import re # Try YYYYMMDD format - match = re.search(r'_(\d{4})(\d{2})(\d{2})(?:\.|$)', filename) + match = re.search(r"_(\d{4})(\d{2})(\d{2})(?:\.|$)", filename) if match: year, month, day = match.groups() date_str = f"{year}-{month}-{day}" @@ -122,7 +120,7 @@ def parse_date_from_filename(filename: str) -> str | None: return date_str # Try YYYY-MM-DD format - match = re.search(r'_(\d{4})-(\d{2})-(\d{2})(?:\.|$)', filename) + match = re.search(r"_(\d{4})-(\d{2})-(\d{2})(?:\.|$)", filename) if match: year, month, day = match.groups() date_str = f"{year}-{month}-{day}" @@ -147,11 +145,13 @@ def get_day_from_date(date_str: str) -> str: >>> get_day_from_date("2023-12-25") "Monday" """ - debug.validate(len(date_str) == 10 and date_str[4] == '-' and date_str[7] == '-', - f"Invalid date format: {date_str}, expected YYYY-MM-DD") + debug.validate( + len(date_str) == 10 and date_str[4] == "-" and date_str[7] == "-", + f"Invalid date format: {date_str}, expected YYYY-MM-DD", + ) try: - year, month, day = map(int, date_str.split('-')) + year, month, day = map(int, date_str.split("-")) d = date(year, month, day) day_name = DAY_NAMES[d.weekday()] debug.print(f"Date {date_str} is {day_name}") @@ -231,11 +231,11 @@ class SecureDeleter: debug.print("File is empty, nothing to overwrite") return - patterns = [b'\x00', b'\xFF', bytes([random.randint(0, 255)])] + patterns = [b"\x00", b"\xff", bytes([random.randint(0, 255)])] for pass_num in range(self.passes): debug.print(f"Overwrite pass {pass_num + 1}/{self.passes}") - with open(file_path, 'r+b') as f: + with open(file_path, "r+b") as f: for pattern_idx, pattern in enumerate(patterns): f.seek(0) # Write pattern in chunks for large files @@ -243,7 +243,7 @@ class SecureDeleter: for offset in range(0, length, chunk_size): chunk = min(chunk_size, length - offset) f.write(pattern * (chunk // len(pattern))) - f.write(pattern[:chunk % len(pattern)]) + f.write(pattern[: chunk % len(pattern)]) # Final pass with random data f.seek(0) @@ -271,7 +271,7 @@ class SecureDeleter: # First, securely overwrite all files file_count = 0 - for file_path in self.path.rglob('*'): + for file_path in self.path.rglob("*"): if file_path.is_file(): self._overwrite_file(file_path) file_count += 1 @@ -325,9 +325,9 @@ def format_file_size(size_bytes: int) -> str: debug.validate(size_bytes >= 0, f"File size cannot be negative: {size_bytes}") size: float = float(size_bytes) - for unit in ['B', 'KB', 'MB', 'GB']: + for unit in ["B", "KB", "MB", "GB"]: if size < 1024: - if unit == 'B': + if unit == "B": return f"{int(size)} {unit}" return f"{size:.1f} {unit}" size /= 1024 diff --git a/src/stegasoo/validation.py b/src/stegasoo/validation.py index 862b903..e90dc3b 100644 --- a/src/stegasoo/validation.py +++ b/src/stegasoo/validation.py @@ -66,11 +66,9 @@ def validate_pin(pin: str, required: bool = False) -> ValidationResult: return ValidationResult.error("PIN must contain only digits") if len(pin) < MIN_PIN_LENGTH or len(pin) > MAX_PIN_LENGTH: - return ValidationResult.error( - f"PIN must be {MIN_PIN_LENGTH}-{MAX_PIN_LENGTH} digits" - ) + return ValidationResult.error(f"PIN must be {MIN_PIN_LENGTH}-{MAX_PIN_LENGTH} digits") - if pin[0] == '0': + if pin[0] == "0": return ValidationResult.error("PIN cannot start with zero") return ValidationResult.ok(length=len(pin)) @@ -121,9 +119,7 @@ def validate_payload(payload: str | bytes | FilePayload) -> ValidationResult: ) return ValidationResult.ok( - size=len(payload.data), - filename=payload.filename, - mime_type=payload.mime_type + size=len(payload.data), filename=payload.filename, mime_type=payload.mime_type ) elif isinstance(payload, bytes): @@ -143,9 +139,7 @@ def validate_payload(payload: str | bytes | FilePayload) -> ValidationResult: def validate_file_payload( - file_data: bytes, - filename: str = "", - max_size: int = MAX_FILE_PAYLOAD_SIZE + file_data: bytes, filename: str = "", max_size: int = MAX_FILE_PAYLOAD_SIZE ) -> ValidationResult: """ Validate a file for embedding. @@ -173,9 +167,7 @@ def validate_file_payload( def validate_image( - image_data: bytes, - name: str = "Image", - check_size: bool = True + image_data: bytes, name: str = "Image", check_size: bool = True ) -> ValidationResult: """ Validate image data and dimensions. @@ -202,18 +194,14 @@ def validate_image( num_pixels = width * height if check_size and num_pixels > MAX_IMAGE_PIXELS: - max_dim = int(MAX_IMAGE_PIXELS ** 0.5) + max_dim = int(MAX_IMAGE_PIXELS**0.5) return ValidationResult.error( f"{name} too large ({width}×{height} = {num_pixels:,} pixels). " f"Maximum: ~{MAX_IMAGE_PIXELS:,} pixels ({max_dim}×{max_dim})" ) return ValidationResult.ok( - width=width, - height=height, - pixels=num_pixels, - mode=img.mode, - format=img.format + width=width, height=height, pixels=num_pixels, mode=img.mode, format=img.format ) except Exception as e: @@ -221,9 +209,7 @@ def validate_image( def validate_rsa_key( - key_data: bytes, - password: str | None = None, - required: bool = False + key_data: bytes, password: str | None = None, required: bool = False ) -> ValidationResult: """ Validate RSA private key. @@ -256,10 +242,7 @@ def validate_rsa_key( return ValidationResult.error(str(e)) -def validate_security_factors( - pin: str, - rsa_key_data: bytes | None -) -> ValidationResult: +def validate_security_factors(pin: str, rsa_key_data: bytes | None) -> ValidationResult: """ Validate that at least one security factor is provided. @@ -274,17 +257,13 @@ def validate_security_factors( has_key = bool(rsa_key_data and len(rsa_key_data) > 0) if not has_pin and not has_key: - return ValidationResult.error( - "You must provide at least a PIN or RSA Key" - ) + return ValidationResult.error("You must provide at least a PIN or RSA Key") return ValidationResult.ok(has_pin=has_pin, has_key=has_key) def validate_file_extension( - filename: str, - allowed: set[str], - file_type: str = "File" + filename: str, allowed: set[str], file_type: str = "File" ) -> ValidationResult: """ Validate file extension. @@ -297,10 +276,10 @@ def validate_file_extension( Returns: ValidationResult with extension """ - if not filename or '.' not in filename: + if not filename or "." not in filename: return ValidationResult.error(f"{file_type} must have a file extension") - ext = filename.rsplit('.', 1)[1].lower() + ext = filename.rsplit(".", 1)[1].lower() if ext not in allowed: return ValidationResult.error( @@ -368,7 +347,7 @@ def validate_passphrase(passphrase: str) -> ValidationResult: if len(words) < RECOMMENDED_PASSPHRASE_WORDS: return ValidationResult.ok( word_count=len(words), - warning=f"Recommend {RECOMMENDED_PASSPHRASE_WORDS}+ words for better security" + warning=f"Recommend {RECOMMENDED_PASSPHRASE_WORDS}+ words for better security", ) return ValidationResult.ok(word_count=len(words)) @@ -378,6 +357,7 @@ def validate_passphrase(passphrase: 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") @@ -418,7 +398,7 @@ def validate_dct_output_format(format_str: str) -> ValidationResult: Returns: ValidationResult """ - valid_formats = {'png', 'jpeg'} + valid_formats = {"png", "jpeg"} if format_str.lower() not in valid_formats: return ValidationResult.error( @@ -438,7 +418,7 @@ def validate_dct_color_mode(mode: str) -> ValidationResult: Returns: ValidationResult """ - valid_modes = {'grayscale', 'color'} + valid_modes = {"grayscale", "color"} if mode.lower() not in valid_modes: return ValidationResult.error( @@ -452,6 +432,7 @@ def validate_dct_color_mode(mode: str) -> ValidationResult: # EXCEPTION-RAISING VALIDATORS (for CLI/API use) # ============================================================================ + def require_valid_pin(pin: str, required: bool = False) -> None: """Validate PIN, raising exception on failure.""" result = validate_pin(pin, required) @@ -481,9 +462,7 @@ def require_valid_image(image_data: bytes, name: str = "Image") -> None: def require_valid_rsa_key( - key_data: bytes, - password: str | None = None, - required: bool = False + key_data: bytes, password: str | None = None, required: bool = False ) -> None: """Validate RSA key, raising exception on failure.""" result = validate_rsa_key(key_data, password, required) diff --git a/tests/test_batch.py b/tests/test_batch.py index 8618b9c..d0a3a45 100644 --- a/tests/test_batch.py +++ b/tests/test_batch.py @@ -41,8 +41,8 @@ def sample_images(temp_dir): images = [] for i in range(3): img_path = temp_dir / f"test_image_{i}.png" - img = Image.new('RGB', (100, 100), color=(i * 50, i * 50, i * 50)) - img.save(img_path, 'PNG') + img = Image.new("RGB", (100, 100), color=(i * 50, i * 50, i * 50)) + img.save(img_path, "PNG") images.append(img_path) return images @@ -55,9 +55,9 @@ def sample_reference_photo(): from PIL import Image - img = Image.new('RGB', (100, 100), color=(128, 128, 128)) + img = Image.new("RGB", (100, 100), color=(128, 128, 128)) buf = BytesIO() - img.save(buf, 'PNG') + img.save(buf, "PNG") return buf.getvalue() @@ -67,7 +67,7 @@ def sample_credentials(sample_reference_photo): return { "reference_photo": sample_reference_photo, "passphrase": "test phrase four words", # v3.2.0: single passphrase - "pin": "123456" + "pin": "123456", } @@ -95,9 +95,9 @@ class TestBatchItem: message="Done", ) result = item.to_dict() - assert result['input_path'] == "input.png" - assert result['output_path'] == "output.png" - assert result['status'] == "success" + assert result["input_path"] == "input.png" + assert result["output_path"] == "output.png" + assert result["status"] == "success" class TestBatchResult: @@ -106,11 +106,12 @@ class TestBatchResult: def test_to_json(self): """Should serialize to valid JSON.""" import json + result = BatchResult(operation="encode", total=5, succeeded=4, failed=1) json_str = result.to_json() parsed = json.loads(json_str) - assert parsed['operation'] == "encode" - assert parsed['summary']['total'] == 5 + assert parsed["operation"] == "encode" + assert parsed["summary"]["total"] == 5 def test_duration_with_end_time(self): """Duration should work when end_time is set.""" @@ -128,7 +129,7 @@ class TestBatchCredentials: data = { "reference_photo": sample_reference_photo, "passphrase": "test phrase four words", - "pin": "123456" + "pin": "123456", } creds = BatchCredentials.from_dict(data) assert creds.passphrase == "test phrase four words" @@ -139,7 +140,7 @@ class TestBatchCredentials: data = { "reference_photo": sample_reference_photo, "day_phrase": "legacy phrase here", # Old key name - "pin": "123456" + "pin": "123456", } creds = BatchCredentials.from_dict(data) # Should accept old key and map to passphrase @@ -151,19 +152,19 @@ class TestBatchCredentials: creds = BatchCredentials( reference_photo=sample_reference_photo, passphrase="test phrase four words", - pin="123456" + pin="123456", ) result = creds.to_dict() - assert result['passphrase'] == "test phrase four words" - assert result['pin'] == "123456" - assert 'day_phrase' not in result # Old key should not be present + assert result["passphrase"] == "test phrase four words" + assert result["pin"] == "123456" + assert "day_phrase" not in result # Old key should not be present def test_passphrase_is_string(self, sample_reference_photo): """Passphrase should be a string, not a dict.""" creds = BatchCredentials( reference_photo=sample_reference_photo, passphrase="test phrase four words", - pin="123456" + pin="123456", ) assert isinstance(creds.passphrase, str) @@ -216,7 +217,7 @@ class TestBatchProcessor: nested = temp_dir / "nested" nested.mkdir() img_path = nested / "nested.png" - img = Image.new('RGB', (50, 50)) + img = Image.new("RGB", (50, 50)) img.save(img_path) processor = BatchProcessor() @@ -241,7 +242,9 @@ class TestBatchProcessor: message="test", ) - def test_batch_encode_accepts_passphrase_credentials(self, sample_images, temp_dir, sample_credentials): + def test_batch_encode_accepts_passphrase_credentials( + self, sample_images, temp_dir, sample_credentials + ): """Should accept v3.2.0 format credentials with passphrase.""" processor = BatchProcessor() result = processor.batch_encode( @@ -343,9 +346,9 @@ class TestBatchCapacityCheck: """Results should include capacity info.""" results = batch_capacity_check(sample_images) for item in results: - assert 'capacity_bytes' in item - assert 'dimensions' in item - assert 'valid' in item + assert "capacity_bytes" in item + assert "dimensions" in item + assert "valid" in item def test_handles_invalid_files(self, temp_dir): """Should handle non-image files gracefully.""" @@ -354,7 +357,7 @@ class TestBatchCapacityCheck: results = batch_capacity_check([bad_file]) assert len(results) == 1 - assert 'error' in results[0] + assert "error" in results[0] class TestPrintBatchResult: @@ -403,7 +406,7 @@ class TestCredentialsMigration: old_format = { "reference_photo": sample_reference_photo, "phrase": "old style phrase", - "pin": "123456" + "pin": "123456", } # Should not raise creds = BatchCredentials.from_dict(old_format) @@ -414,7 +417,7 @@ class TestCredentialsMigration: old_format = { "reference_photo": sample_reference_photo, "day_phrase": "old day phrase", - "pin": "123456" + "pin": "123456", } creds = BatchCredentials.from_dict(old_format) assert creds.passphrase == "old day phrase" @@ -425,7 +428,7 @@ class TestCredentialsMigration: "reference_photo": sample_reference_photo, "passphrase": "new style passphrase", "day_phrase": "old day phrase", - "pin": "123456" + "pin": "123456", } creds = BatchCredentials.from_dict(mixed_format) assert creds.passphrase == "new style passphrase" diff --git a/tests/test_compression.py b/tests/test_compression.py index d38bfe2..3498ae1 100644 --- a/tests/test_compression.py +++ b/tests/test_compression.py @@ -42,6 +42,7 @@ class TestCompress: def test_compress_incompressible_data(self): """Incompressible data should be stored uncompressed.""" import os + # Random data doesn't compress well data = os.urandom(500) result = compress(data, CompressionAlgorithm.ZLIB) @@ -107,6 +108,7 @@ class TestDecompress: def test_roundtrip_large_data(self): """Large data should survive compress/decompress roundtrip.""" import os + original = os.urandom(50000) compressed = compress(original) result = decompress(compressed) @@ -173,7 +175,7 @@ class TestEdgeCases: def test_unicode_after_encoding(self): """UTF-8 encoded Unicode should compress correctly.""" text = "Hello, 世界! 🎉 " * 100 - data = text.encode('utf-8') + data = text.encode("utf-8") compressed = compress(data) result = decompress(compressed) - assert result.decode('utf-8') == text + assert result.decode("utf-8") == text diff --git a/tests/test_stegasoo.py b/tests/test_stegasoo.py index 4a2dba2..d268717 100644 --- a/tests/test_stegasoo.py +++ b/tests/test_stegasoo.py @@ -37,12 +37,13 @@ from stegasoo.steganography import get_output_format # Fixtures # ============================================================================= + @pytest.fixture def png_image(): """Create a test PNG image.""" - img = Image.new('RGB', (100, 100), color='red') + img = Image.new("RGB", (100, 100), color="red") buf = io.BytesIO() - img.save(buf, format='PNG') + img.save(buf, format="PNG") buf.seek(0) return buf.getvalue() @@ -50,9 +51,9 @@ def png_image(): @pytest.fixture def large_png_image(): """Create a larger test PNG image for DCT mode.""" - img = Image.new('RGB', (400, 400), color='blue') + img = Image.new("RGB", (400, 400), color="blue") buf = io.BytesIO() - img.save(buf, format='PNG') + img.save(buf, format="PNG") buf.seek(0) return buf.getvalue() @@ -60,9 +61,9 @@ def large_png_image(): @pytest.fixture def bmp_image(): """Create a test BMP image.""" - img = Image.new('RGB', (100, 100), color='blue') + img = Image.new("RGB", (100, 100), color="blue") buf = io.BytesIO() - img.save(buf, format='BMP') + img.save(buf, format="BMP") buf.seek(0) return buf.getvalue() @@ -70,9 +71,9 @@ def bmp_image(): @pytest.fixture def jpeg_image(): """Create a test JPEG image.""" - img = Image.new('RGB', (100, 100), color='green') + img = Image.new("RGB", (100, 100), color="green") buf = io.BytesIO() - img.save(buf, format='JPEG') + img.save(buf, format="JPEG") buf.seek(0) return buf.getvalue() @@ -80,9 +81,9 @@ def jpeg_image(): @pytest.fixture def gif_image(): """Create a test GIF image.""" - img = Image.new('RGB', (100, 100), color='yellow') + img = Image.new("RGB", (100, 100), color="yellow") buf = io.BytesIO() - img.save(buf, format='GIF') + img.save(buf, format="GIF") buf.seek(0) return buf.getvalue() @@ -91,6 +92,7 @@ def gif_image(): # Key Generation Tests (v3.2.0 Updated) # ============================================================================= + class TestKeygen: """Tests for key generation functions.""" @@ -99,7 +101,7 @@ class TestKeygen: pin = generate_pin() assert len(pin) == 6 assert pin.isdigit() - assert pin[0] != '0' + assert pin[0] != "0" def test_generate_pin_lengths(self): """PIN generation should work for all valid lengths.""" @@ -129,7 +131,7 @@ class TestKeygen: # v3.2.0: Single passphrase instead of 7 daily phrases assert creds.passphrase is not None assert isinstance(creds.passphrase, str) - assert ' ' in creds.passphrase # Should have multiple words + assert " " in creds.passphrase # Should have multiple words def test_generate_credentials_rsa_only(self): """RSA-only credentials should have single passphrase.""" @@ -180,6 +182,7 @@ class TestKeygen: # Validation Tests (v3.2.0 Updated) # ============================================================================= + class TestValidation: """Tests for validation functions.""" @@ -250,56 +253,59 @@ class TestValidation: # Output Format Tests # ============================================================================= + class TestOutputFormat: """Tests for output format handling.""" def test_png_stays_png(self): """PNG input should produce PNG output.""" - fmt, ext = get_output_format('PNG') - assert fmt == 'PNG' - assert ext == 'png' + fmt, ext = get_output_format("PNG") + assert fmt == "PNG" + assert ext == "png" def test_bmp_stays_bmp(self): """BMP input should produce BMP output.""" - fmt, ext = get_output_format('BMP') - assert fmt == 'BMP' - assert ext == 'bmp' + fmt, ext = get_output_format("BMP") + assert fmt == "BMP" + assert ext == "bmp" def test_jpeg_becomes_png(self): """JPEG input should produce PNG output (lossless).""" - fmt, ext = get_output_format('JPEG') - assert fmt == 'PNG' - assert ext == 'png' + fmt, ext = get_output_format("JPEG") + assert fmt == "PNG" + assert ext == "png" def test_gif_becomes_png(self): """GIF input should produce PNG output.""" - fmt, ext = get_output_format('GIF') - assert fmt == 'PNG' - assert ext == 'png' + fmt, ext = get_output_format("GIF") + assert fmt == "PNG" + assert ext == "png" def test_none_becomes_png(self): """None format should default to PNG.""" fmt, ext = get_output_format(None) - assert fmt == 'PNG' - assert ext == 'png' + assert fmt == "PNG" + assert ext == "png" def test_unknown_becomes_png(self): """Unknown format should default to PNG.""" - fmt, ext = get_output_format('UNKNOWN') - assert fmt == 'PNG' - assert ext == 'png' + fmt, ext = get_output_format("UNKNOWN") + assert fmt == "PNG" + assert ext == "png" # ============================================================================= # Header Overhead Test (v4.0.0) # ============================================================================= + class TestConstants: """Tests for constants and configuration.""" def test_header_overhead_value(self): """Header overhead should be 66 bytes (v4.0.0: added flags byte).""" from stegasoo.steganography import HEADER_OVERHEAD + assert HEADER_OVERHEAD == 66 @@ -307,6 +313,7 @@ class TestConstants: # Encode/Decode Tests (v4.0.0 Updated) # ============================================================================= + class TestEncodeDecode: """Tests for encoding and decoding functions.""" @@ -322,19 +329,19 @@ class TestEncodeDecode: reference_photo=png_image, carrier_image=png_image, passphrase=passphrase, - pin=pin + pin=pin, ) assert result.stego_image is not None assert len(result.stego_image) > 0 - assert result.filename.endswith('.png') + assert result.filename.endswith(".png") # v3.2.0: Use passphrase parameter, no date_str decoded = decode( stego_image=result.stego_image, reference_photo=png_image, passphrase=passphrase, - pin=pin + pin=pin, ) assert decoded.message == message @@ -350,7 +357,7 @@ class TestEncodeDecode: reference_photo=png_image, carrier_image=png_image, passphrase=passphrase, - pin=pin + pin=pin, ) # decode_text returns string directly @@ -358,7 +365,7 @@ class TestEncodeDecode: stego_image=result.stego_image, reference_photo=png_image, passphrase=passphrase, - pin=pin + pin=pin, ) assert decoded_text == message @@ -370,9 +377,9 @@ class TestEncodeDecode: reference_photo=png_image, carrier_image=png_image, passphrase="test phrase here now", - pin="123456" + pin="123456", ) - assert result.filename.endswith('.png') + assert result.filename.endswith(".png") def test_bmp_carrier_produces_bmp(self, bmp_image, png_image): """BMP carrier should produce BMP output.""" @@ -381,9 +388,9 @@ class TestEncodeDecode: reference_photo=png_image, carrier_image=bmp_image, passphrase="test phrase here now", - pin="123456" + pin="123456", ) - assert result.filename.endswith('.bmp') + assert result.filename.endswith(".bmp") def test_jpeg_carrier_produces_png(self, jpeg_image, png_image): """JPEG carrier should produce PNG output (lossless).""" @@ -392,9 +399,9 @@ class TestEncodeDecode: reference_photo=png_image, carrier_image=jpeg_image, passphrase="test phrase here now", - pin="123456" + pin="123456", ) - assert result.filename.endswith('.png') + assert result.filename.endswith(".png") def test_bmp_roundtrip(self, bmp_image, png_image): """Full encode/decode cycle with BMP should work.""" @@ -407,15 +414,15 @@ class TestEncodeDecode: reference_photo=png_image, carrier_image=bmp_image, passphrase=passphrase, - pin=pin + pin=pin, ) - assert result.filename.endswith('.bmp') + assert result.filename.endswith(".bmp") decoded = decode( stego_image=result.stego_image, reference_photo=png_image, passphrase=passphrase, - pin=pin + pin=pin, ) assert decoded.message == message @@ -427,7 +434,7 @@ class TestEncodeDecode: reference_photo=png_image, carrier_image=png_image, passphrase="test phrase here now", - pin="123456" + pin="123456", ) with pytest.raises((stegasoo.DecryptionError, stegasoo.ExtractionError)): @@ -435,7 +442,7 @@ class TestEncodeDecode: stego_image=result.stego_image, reference_photo=png_image, passphrase="test phrase here now", - pin="654321" # Wrong PIN + pin="654321", # Wrong PIN ) def test_wrong_passphrase_fails(self, png_image): @@ -445,7 +452,7 @@ class TestEncodeDecode: reference_photo=png_image, carrier_image=png_image, passphrase="correct phrase here now", - pin="123456" + pin="123456", ) with pytest.raises((stegasoo.DecryptionError, stegasoo.ExtractionError)): @@ -453,7 +460,7 @@ class TestEncodeDecode: stego_image=result.stego_image, reference_photo=png_image, passphrase="wrong phrase here now", # Wrong passphrase - pin="123456" + pin="123456", ) def test_unicode_message(self, png_image): @@ -467,14 +474,14 @@ class TestEncodeDecode: reference_photo=png_image, carrier_image=png_image, passphrase=passphrase, - pin=pin + pin=pin, ) decoded = decode( stego_image=result.stego_image, reference_photo=png_image, passphrase=passphrase, - pin=pin + pin=pin, ) assert decoded.message == message @@ -486,18 +493,20 @@ class TestEncodeDecode: reference_photo=png_image, carrier_image=png_image, passphrase="test phrase here now", - pin="123456" + pin="123456", ) # Filename format: {random_hex}_{YYYYMMDD}.{ext} # e.g., "a1b2c3d4_20251227.png" import re - assert re.search(r'^[a-f0-9]{8}_\d{8}\.png$', result.filename) + + assert re.search(r"^[a-f0-9]{8}_\d{8}\.png$", result.filename) # ============================================================================= # DCT Mode Tests (v3.2.0) # ============================================================================= + class TestDCTMode: """Tests for DCT steganography mode.""" @@ -519,7 +528,7 @@ class TestDCTMode: carrier_image=large_png_image, passphrase=passphrase, pin=pin, - embed_mode='dct' + embed_mode="dct", ) assert result.stego_image is not None @@ -528,7 +537,7 @@ class TestDCTMode: stego_image=result.stego_image, reference_photo=large_png_image, passphrase=passphrase, - pin=pin + pin=pin, ) assert decoded.message == message @@ -545,7 +554,7 @@ class TestDCTMode: carrier_image=large_png_image, passphrase=passphrase, pin=pin, - embed_mode='dct' + embed_mode="dct", ) # Decode with auto mode (default) @@ -554,7 +563,7 @@ class TestDCTMode: reference_photo=large_png_image, passphrase=passphrase, pin=pin, - embed_mode='auto' + embed_mode="auto", ) assert decoded.message == message @@ -564,19 +573,20 @@ class TestDCTMode: # Version Tests # ============================================================================= + class TestVersion: """Tests for version information.""" def test_version_exists(self): """Version string should exist and be valid.""" - assert hasattr(stegasoo, '__version__') - parts = stegasoo.__version__.split('.') + assert hasattr(stegasoo, "__version__") + parts = stegasoo.__version__.split(".") assert len(parts) >= 2 assert all(p.isdigit() for p in parts[:2]) def test_version_is_4_0_0(self): """Version should be 4.0.0 or higher.""" - parts = stegasoo.__version__.split('.') + parts = stegasoo.__version__.split(".") major = int(parts[0]) assert major >= 4 @@ -585,6 +595,7 @@ class TestVersion: # Backward Compatibility Tests # ============================================================================= + class TestBackwardCompatibility: """Tests for backward compatibility handling.""" @@ -596,7 +607,7 @@ class TestBackwardCompatibility: reference_photo=png_image, carrier_image=png_image, day_phrase="old style phrase", # Old parameter name - pin="123456" + pin="123456", ) def test_old_date_str_parameter_raises(self, png_image): @@ -608,7 +619,7 @@ class TestBackwardCompatibility: carrier_image=png_image, passphrase="test phrase here now", pin="123456", - date_str="2025-01-01" # Removed parameter + date_str="2025-01-01", # Removed parameter ) @@ -616,6 +627,7 @@ class TestBackwardCompatibility: # Channel Key Tests (v4.0.0) # ============================================================================= + class TestChannelKey: """Tests for channel key functionality (v4.0.0).""" @@ -624,7 +636,7 @@ class TestChannelKey: key = generate_channel_key() # Format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX (8 groups of 4) assert len(key) == 39 - parts = key.split('-') + parts = key.split("-") assert len(parts) == 8 for part in parts: assert len(part) == 4 @@ -665,7 +677,7 @@ class TestChannelKey: carrier_image=png_image, passphrase=passphrase, pin=pin, - channel_key=channel_key + channel_key=channel_key, ) assert result.stego_image is not None @@ -676,7 +688,7 @@ class TestChannelKey: reference_photo=png_image, passphrase=passphrase, pin=pin, - channel_key=channel_key + channel_key=channel_key, ) assert decoded.message == message @@ -696,7 +708,7 @@ class TestChannelKey: carrier_image=png_image, passphrase=passphrase, pin=pin, - channel_key=channel_key1 + channel_key=channel_key1, ) # Decode with different channel key should fail @@ -706,7 +718,7 @@ class TestChannelKey: reference_photo=png_image, passphrase=passphrase, pin=pin, - channel_key=channel_key2 + channel_key=channel_key2, ) def test_encode_decode_public_mode(self, png_image): @@ -722,7 +734,7 @@ class TestChannelKey: carrier_image=png_image, passphrase=passphrase, pin=pin, - channel_key="" # Explicit public mode + channel_key="", # Explicit public mode ) # Decode without channel key @@ -731,7 +743,7 @@ class TestChannelKey: reference_photo=png_image, passphrase=passphrase, pin=pin, - channel_key="" # Explicit public mode + channel_key="", # Explicit public mode ) assert decoded.message == message @@ -749,7 +761,7 @@ class TestChannelKey: carrier_image=png_image, passphrase=passphrase, pin=pin, - channel_key="" # Public mode + channel_key="", # Public mode ) # Decode with channel key should fail @@ -760,5 +772,5 @@ class TestChannelKey: reference_photo=png_image, passphrase=passphrase, pin=pin, - channel_key=channel_key + channel_key=channel_key, )