diff --git a/.gitignore b/.gitignore index 1e611b0..a12c6d4 100644 --- a/.gitignore +++ b/.gitignore @@ -51,3 +51,6 @@ htmlcov/ # Distribution *.manifest *.spec + +# Output test files. +*.png diff --git a/frontends/api/main.py b/frontends/api/main.py index fc6ec56..b45fe02 100644 --- a/frontends/api/main.py +++ b/frontends/api/main.py @@ -37,6 +37,17 @@ from stegasoo.constants import ( VALID_RSA_SIZES, ) +# QR Code utilities +try: + from stegasoo.qr_utils import ( + extract_key_from_qr, + has_qr_read, + ) + HAS_QR_READ = has_qr_read() +except ImportError: + HAS_QR_READ = False + extract_key_from_qr = None + # ============================================================================ # FASTAPI APP @@ -132,10 +143,17 @@ class ImageInfoResponse(BaseModel): class StatusResponse(BaseModel): version: str has_argon2: bool + has_qrcode_read: bool day_names: list[str] max_payload_kb: int +class QrExtractResponse(BaseModel): + success: bool + key_pem: Optional[str] = None + error: Optional[str] = None + + class ErrorResponse(BaseModel): error: str detail: Optional[str] = None @@ -151,11 +169,43 @@ async def root(): return StatusResponse( version=__version__, has_argon2=has_argon2(), + has_qrcode_read=HAS_QR_READ, day_names=list(DAY_NAMES), max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024 ) +@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") +): + """ + Extract RSA key from a QR code image. + + Supports both compressed (STEGASOO-Z: prefix) and uncompressed keys. + Returns the PEM-encoded key if found. + """ + if not HAS_QR_READ: + raise HTTPException( + 501, + "QR code reading not available. Install pyzbar and libzbar." + ) + + try: + image_data = await qr_image.read() + key_pem = extract_key_from_qr(image_data) + + 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" + ) + except Exception as e: + return QrExtractResponse(success=False, error=str(e)) + + @app.post("/generate", response_model=GenerateResponse) async def api_generate(request: GenerateRequest): """ @@ -335,6 +385,7 @@ async def api_encode_multipart( payload_file: Optional[UploadFile] = File(None), pin: str = Form(""), rsa_key: Optional[UploadFile] = File(None), + rsa_key_qr: Optional[UploadFile] = File(None), rsa_password: str = Form(""), date_str: str = Form("") ): @@ -342,12 +393,34 @@ async def api_encode_multipart( Encode using multipart form data (file uploads). Provide either 'message' (text) or 'payload_file' (binary file). + RSA key can be provided as 'rsa_key' (.pem file) or 'rsa_key_qr' (QR code image). Returns the stego image directly as PNG with metadata headers. """ try: ref_data = await reference_photo.read() carrier_data = await carrier.read() - rsa_key_data = await rsa_key.read() if rsa_key else None + + # Handle RSA key from .pem file or QR code image + rsa_key_data = None + rsa_key_from_qr = False + + if rsa_key and rsa_key.filename: + rsa_key_data = await rsa_key.read() + 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." + ) + 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_from_qr = True + + # QR code keys are never password-protected + effective_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) # Determine payload if payload_file and payload_file.filename: @@ -369,7 +442,7 @@ async def api_encode_multipart( day_phrase=day_phrase, pin=pin, rsa_key_data=rsa_key_data, - rsa_password=rsa_password if rsa_password else None, + rsa_password=effective_password, date_str=date_str if date_str else None ) @@ -401,17 +474,40 @@ async def api_decode_multipart( stego_image: UploadFile = File(...), pin: str = Form(""), rsa_key: Optional[UploadFile] = File(None), + rsa_key_qr: Optional[UploadFile] = File(None), rsa_password: str = Form("") ): """ Decode using multipart form data (file uploads). + RSA key can be provided as 'rsa_key' (.pem file) or 'rsa_key_qr' (QR code image). Returns JSON with payload_type indicating text or file. """ try: ref_data = await reference_photo.read() stego_data = await stego_image.read() - rsa_key_data = await rsa_key.read() if rsa_key else None + + # Handle RSA key from .pem file or QR code image + rsa_key_data = None + rsa_key_from_qr = False + + if rsa_key and rsa_key.filename: + rsa_key_data = await rsa_key.read() + 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." + ) + 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_from_qr = True + + # QR code keys are never password-protected + effective_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) result = decode( stego_image=stego_data, @@ -419,7 +515,7 @@ async def api_decode_multipart( day_phrase=day_phrase, pin=pin, rsa_key_data=rsa_key_data, - rsa_password=rsa_password if rsa_password else None + rsa_password=effective_password ) if result.is_file: diff --git a/frontends/cli/main.py b/frontends/cli/main.py index a6a281a..ce27a14 100644 --- a/frontends/cli/main.py +++ b/frontends/cli/main.py @@ -30,6 +30,20 @@ from stegasoo import ( FilePayload, ) +# QR Code utilities +try: + from stegasoo.qr_utils import ( + extract_key_from_qr_file, + generate_qr_code, + has_qr_read, has_qr_write, + can_fit_in_qr, needs_compression, + ) + HAS_QR = True +except ImportError: + HAS_QR = False + has_qr_read = lambda: False + has_qr_write = lambda: False + # ============================================================================ # CLI SETUP @@ -177,34 +191,36 @@ def generate(pin, rsa, pin_length, rsa_bits, words, output, password, as_json): @click.option('--embed-file', '-e', type=click.Path(exists=True), help='Embed a file (binary)') @click.option('--phrase', '-p', required=True, help='Day phrase') @click.option('--pin', help='Static PIN') -@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file') -@click.option('--key-password', help='RSA key password') +@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file (.pem)') +@click.option('--key-qr', type=click.Path(exists=True), help='RSA key from QR code image') +@click.option('--key-password', help='RSA key password (for encrypted .pem files)') @click.option('--output', '-o', type=click.Path(), help='Output file (default: auto-generated)') @click.option('--date', 'date_str', help='Date override (YYYY-MM-DD)') @click.option('--quiet', '-q', is_flag=True, help='Suppress output except errors') -def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key, key_password, output, date_str, quiet): +def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key, key_qr, key_password, output, date_str, quiet): """ Encode a secret message or file into an image. Requires a reference photo, carrier image, and day phrase. - Must provide either --pin or --key (or both). + Must provide either --pin or --key/--key-qr (or both). For text messages, use -m or -f or pipe via stdin. For binary files, use -e/--embed-file. + RSA key can be provided as a .pem file (--key) or QR code image (--key-qr). \b Examples: - # Text message + # Text message with PIN stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder" --pin 123456 -m "secret" - # Text from file - stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -f message.txt + # With RSA key file + stegasoo encode -r photo.jpg -c meme.png -p "words" -k mykey.pem -m "secret" - # Embed a binary file (PDF, ZIP, etc.) + # With RSA key from QR code image + stegasoo encode -r photo.jpg -c meme.png -p "words" --key-qr keyqr.png -m "secret" + + # Embed a binary file stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 -e secret.pdf - - # Pipe text - echo "secret" | stegasoo encode -r photo.jpg -c meme.png -p "words" --pin 123456 """ # Determine what to encode payload = None @@ -223,14 +239,35 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key else: raise click.UsageError("Must provide message via -m, -f, -e, or stdin") - # Load key if provided + # Load key if provided (from .pem file or QR code image) rsa_key_data = None + rsa_key_from_qr = False + + if key and key_qr: + raise click.UsageError("Cannot use both --key and --key-qr. Choose one.") + if key: rsa_key_data = Path(key).read_bytes() + elif key_qr: + if not HAS_QR or not has_qr_read(): + raise click.ClickException( + "QR code reading not available. Install: pip install pyzbar\n" + "Also requires system library: sudo apt-get install libzbar0" + ) + 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_from_qr = True + if not quiet: + click.echo(f"Loaded RSA key from QR code: {key_qr}") + + # QR code keys are never password-protected + effective_key_password = None if rsa_key_from_qr else key_password # Validate security factors if not pin and not rsa_key_data: - raise click.UsageError("Must provide --pin or --key (or both)") + raise click.UsageError("Must provide --pin or --key/--key-qr (or both)") try: ref_photo = Path(ref).read_bytes() @@ -243,7 +280,7 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key day_phrase=phrase, pin=pin or "", rsa_key_data=rsa_key_data, - rsa_password=key_password, + rsa_password=effective_key_password, date_str=date_str, ) @@ -278,37 +315,63 @@ def encode_cmd(ref, carrier, message, message_file, embed_file, phrase, pin, key @click.option('--stego', '-s', required=True, type=click.Path(exists=True), help='Stego image') @click.option('--phrase', '-p', required=True, help='Day phrase') @click.option('--pin', help='Static PIN') -@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file') -@click.option('--key-password', help='RSA key password') +@click.option('--key', '-k', type=click.Path(exists=True), help='RSA key file (.pem)') +@click.option('--key-qr', type=click.Path(exists=True), help='RSA key from QR code image') +@click.option('--key-password', help='RSA key password (for encrypted .pem files)') @click.option('--output', '-o', type=click.Path(), help='Save decoded content to file') @click.option('--quiet', '-q', is_flag=True, help='Output only the content (for text) or suppress messages (for files)') @click.option('--force', is_flag=True, help='Overwrite existing output file') -def decode_cmd(ref, stego, phrase, pin, key, key_password, output, quiet, force): +def decode_cmd(ref, stego, phrase, pin, key, key_qr, key_password, output, quiet, force): """ Decode a secret message or file from a stego image. Must use the same credentials that were used for encoding. Automatically detects whether content is text or a file. + RSA key can be provided as a .pem file (--key) or QR code image (--key-qr). \b Examples: - # Decode and print text + # Decode with PIN stegasoo decode -r photo.jpg -s stego.png -p "apple forest thunder" --pin 123456 - # Decode and save (auto-detect type) - stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 -o output.txt + # Decode with RSA key file + stegasoo decode -r photo.jpg -s stego.png -p "words" -k mykey.pem - # Quiet mode for piping text - stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 -q | less + # Decode with RSA key from QR code image + stegasoo decode -r photo.jpg -s stego.png -p "words" --key-qr keyqr.png + + # Save output to file + stegasoo decode -r photo.jpg -s stego.png -p "words" --pin 123456 -o output.txt """ - # Load key if provided + # Load key if provided (from .pem file or QR code image) rsa_key_data = None + rsa_key_from_qr = False + + if key and key_qr: + raise click.UsageError("Cannot use both --key and --key-qr. Choose one.") + if key: rsa_key_data = Path(key).read_bytes() + elif key_qr: + if not HAS_QR or not has_qr_read(): + raise click.ClickException( + "QR code reading not available. Install: pip install pyzbar\n" + "Also requires system library: sudo apt-get install libzbar0" + ) + 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_from_qr = True + if not quiet: + click.echo(f"Loaded RSA key from QR code: {key_qr}") + + # QR code keys are never password-protected + effective_key_password = None if rsa_key_from_qr else key_password # Validate security factors if not pin and not rsa_key_data: - raise click.UsageError("Must provide --pin or --key (or both)") + raise click.UsageError("Must provide --pin or --key/--key-qr (or both)") try: ref_photo = Path(ref).read_bytes() @@ -320,7 +383,7 @@ def decode_cmd(ref, stego, phrase, pin, key, key_password, output, quiet, force) day_phrase=phrase, pin=pin or "", rsa_key_data=rsa_key_data, - rsa_password=key_password, + rsa_password=effective_key_password, ) if result.is_file: diff --git a/frontends/web/app.py b/frontends/web/app.py index f12a2cf..6ae11e3 100644 --- a/frontends/web/app.py +++ b/frontends/web/app.py @@ -41,6 +41,33 @@ from stegasoo.constants import ( VALID_RSA_SIZES, MAX_FILE_SIZE, ) +# QR Code support +try: + import qrcode + from qrcode.constants import ERROR_CORRECT_L, ERROR_CORRECT_M + HAS_QRCODE = True +except ImportError: + HAS_QRCODE = False + +# QR Code reading +try: + from pyzbar.pyzbar import decode as pyzbar_decode + HAS_QRCODE_READ = True +except ImportError: + HAS_QRCODE_READ = False + +import zlib +import base64 + +# Import QR utilities +from stegasoo.qr_utils import ( + compress_data, decompress_data, auto_decompress, + is_compressed, can_fit_in_qr, needs_compression, + generate_qr_code, read_qr_code, extract_key_from_qr, + has_qr_write, has_qr_read, + QR_MAX_BINARY, COMPRESSION_PREFIX +) + # ============================================================================ # FLASK APP CONFIGURATION @@ -81,6 +108,7 @@ def format_size(size_bytes: int) -> str: return f"{size_bytes / (1024 * 1024):.1f} MB" + # ============================================================================ # ROUTES # ============================================================================ @@ -99,7 +127,7 @@ def generate(): 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_ml=False) + 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)) @@ -119,6 +147,31 @@ def generate(): words_per_phrase=words_per_phrase ) + # Store RSA key temporarily for QR generation + qr_token = None + qr_needs_compression = False + qr_too_large = False + + if creds.rsa_key_pem and HAS_QRCODE: + # Check if key fits in QR code + if can_fit_in_qr(creds.rsa_key_pem, compress=False): + qr_needs_compression = False + elif can_fit_in_qr(creds.rsa_key_pem, compress=True): + qr_needs_compression = True + else: + qr_too_large = True + + if not qr_too_large: + 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 + } + return render_template('generate.html', phrases=creds.phrases, pin=creds.pin, @@ -134,19 +187,118 @@ def generate(): pin_entropy=creds.pin_entropy, rsa_entropy=creds.rsa_entropy, total_entropy=creds.total_entropy, - has_ml=False + has_qrcode=HAS_QRCODE, + qr_token=qr_token, + qr_needs_compression=qr_needs_compression, + 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_ml=False) + return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) - return render_template('generate.html', generated=False, has_ml=False) + return render_template('generate.html', generated=False, has_qrcode=HAS_QRCODE) + + +@app.route('/generate/qr/') +def generate_qr(token): + """Generate QR code for RSA key.""" + if not HAS_QRCODE: + return "QR code support not available", 501 + + if token not in TEMP_FILES: + return "Token expired or invalid", 404 + + file_info = TEMP_FILES[token] + 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) + qr_png = generate_qr_code(key_pem, compress=compress) + + 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/') +def generate_qr_download(token): + """Download QR code as PNG file.""" + if not HAS_QRCODE: + return "QR code support not available", 501 + + if token not in TEMP_FILES: + return "Token expired or invalid", 404 + + file_info = TEMP_FILES[token] + 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) + qr_png = generate_qr_code(key_pem, compress=compress) + + return send_file( + io.BytesIO(qr_png), + mimetype='image/png', + as_attachment=True, + download_name='stegasoo_rsa_key_qr.png' + ) + except Exception as e: + return f"Error generating QR code: {e}", 500 @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', '') + + +@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 + + qr_image = request.files.get('qr_image') + if not qr_image: + 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 + }) + else: + 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 password = request.form.get('key_password', '') if not key_pem: @@ -190,11 +342,11 @@ def encode_page(): if not ref_photo or not carrier: flash('Both reference photo and carrier image are required', 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, 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', day_of_week=day_of_week, max_payload_kb=max_payload_kb) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) # Get form data message = request.form.get('message', '') @@ -211,7 +363,7 @@ def encode_page(): result = validate_file_payload(file_data, payload_file.filename) if not result.is_valid: flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) mime_type, _ = mimetypes.guess_type(payload_file.filename) payload = FilePayload( @@ -224,43 +376,64 @@ def encode_page(): result = validate_message(message) if not result.is_valid: flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) payload = message if not day_phrase: flash('Day phrase is required', 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) # Read files ref_data = ref_photo.read() carrier_data = carrier.read() - rsa_key_data = rsa_key_file.read() if rsa_key_file and rsa_key_file.filename else None + + # 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_from_qr = False # Track source for password handling + + if rsa_key_file and rsa_key_file.filename: + # RSA key from .pem file + rsa_key_data = rsa_key_file.read() + elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ: + # RSA key from QR code image + 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_from_qr = True # QR keys are never password-protected + else: + flash('Could not extract RSA key from QR code image. Make sure the image contains a valid QR code.', 'error') + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, 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', day_of_week=day_of_week, max_payload_kb=max_payload_kb) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, 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', day_of_week=day_of_week, max_payload_kb=max_payload_kb) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) + + # Determine key password - QR code keys are never password-protected + key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) # Validate RSA key if provided if rsa_key_data: - result = validate_rsa_key(rsa_key_data, rsa_password if rsa_password else None) + result = validate_rsa_key(rsa_key_data, key_password) if not result.is_valid: flash(result.error_message, 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, 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', day_of_week=day_of_week, max_payload_kb=max_payload_kb) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) # Get date client_date = request.form.get('client_date', '').strip() @@ -277,7 +450,7 @@ def encode_page(): day_phrase=day_phrase, pin=pin, rsa_key_data=rsa_key_data, - rsa_password=rsa_password if rsa_password else None, + rsa_password=key_password, date_str=date_str ) @@ -294,15 +467,15 @@ def encode_page(): except CapacityError as e: flash(str(e), 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) except StegasooError as e: flash(str(e), 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) except Exception as e: flash(f'Error: {e}', 'error') - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) - return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb) + return render_template('encode.html', day_of_week=day_of_week, max_payload_kb=max_payload_kb, has_qrcode_read=HAS_QRCODE_READ) @app.route('/encode/result/') @@ -366,7 +539,7 @@ def decode_page(): if not ref_photo or not stego_image: flash('Both reference photo and stego image are required', 'error') - return render_template('decode.html') + return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) # Get form data day_phrase = request.form.get('day_phrase', '') @@ -375,32 +548,53 @@ def decode_page(): if not day_phrase: flash('Day phrase is required', 'error') - return render_template('decode.html') + return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) # Read files ref_data = ref_photo.read() stego_data = stego_image.read() - rsa_key_data = rsa_key_file.read() if rsa_key_file and rsa_key_file.filename else None + + # 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_from_qr = False # Track source for password handling + + if rsa_key_file and rsa_key_file.filename: + # RSA key from .pem file + rsa_key_data = rsa_key_file.read() + elif rsa_key_qr and rsa_key_qr.filename and HAS_QRCODE_READ: + # RSA key from QR code image + 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_from_qr = True # QR keys are never password-protected + else: + flash('Could not extract RSA key from QR code image. Make sure the image contains a valid QR code.', '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') + 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') + return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) + + # Determine key password - QR code keys are never password-protected + key_password = None if rsa_key_from_qr else (rsa_password if rsa_password else None) # Validate RSA key if provided if rsa_key_data: - result = validate_rsa_key(rsa_key_data, rsa_password if rsa_password else None) + result = validate_rsa_key(rsa_key_data, key_password) if not result.is_valid: flash(result.error_message, 'error') - return render_template('decode.html') + return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) # Decode decode_result = decode( @@ -409,7 +603,7 @@ def decode_page(): day_phrase=day_phrase, pin=pin, rsa_key_data=rsa_key_data, - rsa_password=rsa_password if rsa_password else None + rsa_password=key_password ) if decode_result.is_file: @@ -438,15 +632,15 @@ def decode_page(): except DecryptionError: flash('Decryption failed. Check your phrase, PIN, RSA key, and reference photo.', 'error') - return render_template('decode.html') + return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) except StegasooError as e: flash(str(e), 'error') - return render_template('decode.html') + 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') + return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) - return render_template('decode.html') + return render_template('decode.html', has_qrcode_read=HAS_QRCODE_READ) @app.route('/decode/download/') @@ -471,6 +665,7 @@ def decode_download(file_id): def about(): return render_template('about.html', has_argon2=has_argon2(), + has_qrcode_read=HAS_QRCODE_READ, max_payload_kb=MAX_FILE_PAYLOAD_SIZE // 1024 ) diff --git a/frontends/web/templates/about.html b/frontends/web/templates/about.html index 7ac6af0..c275f0e 100644 --- a/frontends/web/templates/about.html +++ b/frontends/web/templates/about.html @@ -10,37 +10,239 @@
About Stegasoo
-

- Stegasoo is a hybrid steganography system that hides encrypted messages inside - ordinary images. It combines multiple security layers to create a system that is - both highly secure and practical to use. +

+ Stegasoo is a secure steganography tool that hides encrypted messages and files + inside ordinary images using multi-factor authentication.

-
System Status
-
+
Key Features
+
-
- {% if has_argon2 %} - -
- Argon2id Available -
Memory-hard key derivation (256MB)
-
- {% else %} - -
- Using PBKDF2 Fallback -
Install argon2-cffi for better security
-
- {% endif %} -
+
    +
  • + + Text & File Embedding — Hide messages or any file type (PDF, ZIP, documents) +
  • +
  • + + Multi-Factor Security — Combines photo + phrase + PIN/RSA key +
  • +
  • + + AES-256-GCM Encryption — Military-grade authenticated encryption +
  • +
  • + + Daily Rotating Phrases — Different passphrase each day of the week +
  • +
-
- -
- AES-256-GCM -
Authenticated encryption enabled
+
    +
  • + + Random Pixel Embedding — Defeats statistical steganalysis +
  • +
  • + + Format Preservation — Maintains PNG/BMP lossless formats +
  • +
  • + + Large Capacity — Up to {{ max_payload_kb }} KB payload, 16MP images +
  • +
  • + + Zero Server Storage — Nothing saved, files auto-expire +
  • +
+
+
+
+
+ +
+
+
How Security Works
+
+
+

Stegasoo uses hybrid multi-factor authentication to derive encryption keys:

+ +
+
+
+ + Reference Photo +
Something you have
+
~80-256 bits
+
+
+
+
+ + Daily Phrase +
Something you know (rotates)
+
~33 bits (3 words)
+
+
+
+
+ + Static PIN +
Something you know (fixed)
+
~20 bits (6 digits)
+
+
+
+
+ + RSA Key +
Something you have (optional)
+
~128 bits (2048-bit)
+
+
+
+ +
+ + Combined entropy: 130-400+ bits depending on configuration. + For reference, 128 bits is considered computationally infeasible to brute force. +
+ +
Key Derivation
+

+ {% if has_argon2 %} + Argon2id Available + Using Argon2id with 256MB memory cost — the winner of the Password Hashing Competition + and current best practice for key derivation. + {% else %} + Argon2 Not Available + Falling back to PBKDF2-SHA512 with 600,000 iterations. + Install argon2-cffi for stronger security. + {% endif %} +

+ +
Steganography Technique
+

+ Uses LSB (Least Significant Bit) embedding with pseudo-random pixel selection. + The pixel locations are determined by a key derived from your credentials, making the + hidden data's location unpredictable without the correct inputs. +

+
+
+ +
+
+
File Embedding
+
+
+

+ New in v2.1 + Stegasoo now supports embedding any file type, not just text messages. +

+ +
+
+
Supported
+
    +
  • PDF documents
  • +
  • ZIP/RAR archives
  • +
  • Office documents (DOCX, XLSX, PPTX)
  • +
  • Source code files
  • +
  • Any binary file up to {{ max_payload_kb }} KB
  • +
+
+
+
How It Works
+
    +
  • Original filename is preserved
  • +
  • MIME type is stored for proper handling
  • +
  • File is encrypted identically to text
  • +
  • Decoding auto-detects text vs. file
  • +
+
+
+ +
+ + Tip: For larger files, compress them first (ZIP) to maximize capacity. + A 16MP carrier image can hold approximately 6MB of raw data, but we limit payloads + to {{ max_payload_kb }} KB for reasonable processing times. +
+
+
+ +
+
+
Usage Guide
+
+
+
+
+

+ +

+
+
+
    +
  1. Both parties agree on a reference photo (shared secretly, never transmitted)
  2. +
  3. Go to Generate and create credentials
  4. +
  5. Memorize the 7 daily phrases and PIN
  6. +
  7. If using RSA, download and securely store the key file
  8. +
  9. Share credentials with your contact through a secure channel
  10. +
+
+
+
+ +
+

+ +

+
+
+
    +
  1. Go to Encode
  2. +
  3. Upload your reference photo
  4. +
  5. Upload a carrier image (the image to hide data in)
  6. +
  7. Choose Text or File mode
  8. +
  9. Enter your message or select a file to embed
  10. +
  11. Enter today's phrase and your PIN/key
  12. +
  13. Download the resulting stego image
  14. +
  15. Send the stego image through any channel (email, social media, etc.)
  16. +
+
+
+
+ +
+

+ +

+
+
+
    +
  1. Go to Decode
  2. +
  3. Upload your reference photo (same one used for encoding)
  4. +
  5. Upload the stego image you received
  6. +
  7. Enter the phrase for the day it was encoded (check the filename for date)
  8. +
  9. Enter your PIN and/or RSA key
  10. +
  11. View the decoded message or download the extracted file
  12. +
+
+ + The stego image filename contains the encoding date (e.g., abc123_20251228.png). + Use this to determine which day's phrase to use! +
@@ -50,129 +252,100 @@
-
Security Model
+
Limits & Specifications
-
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
ComponentEntropyPurpose
Reference Photo~80-256 bitsSomething you have (plausible deniability)
3-Word Phrase~33 bitsSomething you know (changes daily)
6-Digit PIN~20 bitsSomething you know (static)
DateN/AAutomatic key rotation
Combined133+ bitsBeyond brute force
-
-
-
- -
-
-
Attack Resistance
-
-
-
-
-
What Attackers Can't Do
-
    -
  • - - Brute force the passphrase (2133 combinations) -
  • -
  • - - Use rainbow tables (random salt per message) -
  • -
  • - - Detect hidden data (random pixel selection) -
  • -
  • - - Use GPU farms (Argon2 requires 256MB RAM per attempt) -
  • -
-
-
-
Real Threats
-
    -
  • - - Social engineering (someone tricks you) -
  • -
  • - - Physical access to your devices -
  • -
  • - - Malware/keyloggers on your system -
  • -
  • - - Shoulder surfing while you type -
  • -
-
-
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
Max text message250,000 characters (~250 KB)
Max file payload{{ max_payload_kb }} KB
Max carrier image16 megapixels (~4000×4000)
Max upload size10 MB
Temp file expiry5 minutes
PIN length6-9 digits
RSA key sizes2048, 3072, 4096 bits
Phrase length3-12 words (BIP-39 wordlist)
-
Best Practices
+
CLI & API
-
-
-
Do
-
    -
  • Memorize your phrases and PIN, never write them down
  • -
  • Use a reference photo that both parties already have
  • -
  • Use different carrier images for each message
  • -
  • Share stego images through normal channels (looks innocent)
  • -
-
-
-
Don't
-
    -
  • Don't transmit the reference photo
  • -
  • Don't reuse the same carrier image
  • -
  • Don't store phrases or PIN digitally
  • -
  • Don't resize or recompress stego images
  • -
-
-
+

Stegasoo is also available as a command-line tool and REST API:

+ +
Command Line
+
# Generate credentials
+stegasoo generate --pin --rsa
+
+# Encode a text message
+stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder" --pin 123456 -m "secret"
+
+# Encode a file
+stegasoo encode -r photo.jpg -c meme.png -p "apple forest thunder" --pin 123456 -e document.pdf
+
+# Decode (auto-detects text vs file)
+stegasoo decode -r photo.jpg -s stego.png -p "apple forest thunder" --pin 123456
+ +
REST API
+
# Encode with multipart upload
+curl -X POST http://localhost:8000/encode/multipart \
+  -F "reference_photo=@photo.jpg" \
+  -F "carrier=@meme.png" \
+  -F "message=secret" \
+  -F "day_phrase=apple forest thunder" \
+  -F "pin=123456" \
+  --output stego.png
+
+# Encode a file
+curl -X POST http://localhost:8000/encode/multipart \
+  -F "reference_photo=@photo.jpg" \
+  -F "carrier=@meme.png" \
+  -F "payload_file=@document.pdf" \
+  -F "day_phrase=apple forest thunder" \
+  -F "pin=123456" \
+  --output stego.png
+ +

+ API documentation available at /docs (Swagger) or /redoc when running the API server. +

+ +
+

+ Stegasoo v2.1.0 • + Open Source • + Built with Python, Flask, and cryptography +

+
{% endblock %} diff --git a/frontends/web/templates/base.html b/frontends/web/templates/base.html index 7254369..190c289 100644 --- a/frontends/web/templates/base.html +++ b/frontends/web/templates/base.html @@ -63,7 +63,7 @@
- Stegasoo v1.1 — Hybrid Photo + Day-Phrase + PIN Steganography + Stegasoo v2.1.0 — Hybrid Photo + Day-Phrase + PIN Steganography
diff --git a/frontends/web/templates/decode.html b/frontends/web/templates/decode.html index cfb28e6..75d47a3 100644 --- a/frontends/web/templates/decode.html +++ b/frontends/web/templates/decode.html @@ -133,10 +133,33 @@ - + {% if has_qrcode_read %} + +
+
+ +
+
+ +
PNG, JPG, or other image of QR code
+
+
+ {% else %} + + {% endif %}
- If RSA key was used during encoding + If RSA key was used during encoding (file or QR image)
@@ -204,13 +227,32 @@ document.getElementById('decodeForm')?.addEventListener('submit', function() { btn.disabled = true; }); -// Show RSA password field when key is selected +// Show RSA password field when key is selected (only for .pem files, not QR) const rsaKeyInput = document.getElementById('rsaKeyInput'); +const rsaKeyQrInput = document.getElementById('rsaKeyQrInput'); const rsaPasswordGroup = document.getElementById('rsaPasswordGroup'); -rsaKeyInput?.addEventListener('change', function() { - rsaPasswordGroup.classList.toggle('d-none', !this.files.length); -}); +if (rsaKeyInput) { + rsaKeyInput.addEventListener('change', function() { + // Show password field only for .pem files + rsaPasswordGroup.classList.toggle('d-none', !this.files.length); + // Clear QR input if file is selected + if (rsaKeyQrInput && this.files.length) { + rsaKeyQrInput.value = ''; + } + }); +} + +if (rsaKeyQrInput) { + rsaKeyQrInput.addEventListener('change', function() { + // Hide password field for QR codes (they're unencrypted) + rsaPasswordGroup.classList.add('d-none'); + // Clear file input if QR is selected + if (rsaKeyInput && this.files.length) { + rsaKeyInput.value = ''; + } + }); +} // Day names for date detection const dayNames = ['Sunday', 'Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday']; diff --git a/frontends/web/templates/encode.html b/frontends/web/templates/encode.html index a6b37f5..4098675 100644 --- a/frontends/web/templates/encode.html +++ b/frontends/web/templates/encode.html @@ -142,10 +142,33 @@ - + {% if has_qrcode_read %} + +
+
+ +
+
+ +
PNG, JPG, or other image of QR code
+
+
+ {% else %} + + {% endif %}
- Your shared .pem key file (if configured) + Your shared .pem key file or QR code image (if configured)
@@ -158,7 +181,7 @@
- Leave blank if your key file is not password-protected + Leave blank if your key file is not password-protected (not needed for QR codes)
@@ -270,13 +293,32 @@ function formatFileSize(bytes) { return (bytes / (1024 * 1024)).toFixed(1) + ' MB'; } -// Show RSA password field when key is selected +// Show RSA password field when key is selected (only for .pem files, not QR) const rsaKeyInput = document.getElementById('rsaKeyInput'); +const rsaKeyQrInput = document.getElementById('rsaKeyQrInput'); const rsaPasswordGroup = document.getElementById('rsaPasswordGroup'); -rsaKeyInput.addEventListener('change', function() { - rsaPasswordGroup.classList.toggle('d-none', !this.files.length); -}); +if (rsaKeyInput) { + rsaKeyInput.addEventListener('change', function() { + // Show password field only for .pem files + rsaPasswordGroup.classList.toggle('d-none', !this.files.length); + // Clear QR input if file is selected + if (rsaKeyQrInput && this.files.length) { + rsaKeyQrInput.value = ''; + } + }); +} + +if (rsaKeyQrInput) { + rsaKeyQrInput.addEventListener('change', function() { + // Hide password field for QR codes (they're unencrypted) + rsaPasswordGroup.classList.add('d-none'); + // Clear file input if QR is selected + if (rsaKeyInput && this.files.length) { + rsaKeyInput.value = ''; + } + }); +} // Form submit loading state document.getElementById('encodeForm').addEventListener('submit', function(e) { diff --git a/frontends/web/templates/encode_result.html b/frontends/web/templates/encode_result.html index c5a268d..a691ab3 100644 --- a/frontends/web/templates/encode_result.html +++ b/frontends/web/templates/encode_result.html @@ -1,61 +1,50 @@ {% extends "base.html" %} -{% block title %}Message Encoded - Stegasoo{% endblock %} +{% block title %}Encode Success - Stegasoo{% endblock %} {% block content %}
-
+
-
-
Message Encoded Successfully!
+
+
Encoding Successful!
-
- -
{{ filename }}
-

Your secret message is hidden in this image

+
+
-
+

Your secret has been hidden in the image.

+ +
+ {{ filename }} +
+ +
Download Image -
- -
-

Share via:

- -
-
- - File expires in 5 minutes. - Download or share now. The file will be securely deleted after expiry. + + Important: +
    +
  • This file expires in 5 minutes
  • +
  • Do not resize or recompress the image
  • +
  • PNG format preserves your hidden data
  • +
- - Encode Another Message + + Encode Another Message
@@ -65,78 +54,42 @@ {% block scripts %} {% endblock %} diff --git a/frontends/web/templates/generate.html b/frontends/web/templates/generate.html index 494415b..fd8c8c2 100644 --- a/frontends/web/templates/generate.html +++ b/frontends/web/templates/generate.html @@ -3,18 +3,6 @@ {% block title %}Generate Credentials - Stegasoo{% endblock %} {% block content %} -
@@ -23,335 +11,479 @@
{% if not generated %} -

- Generate your weekly phrase card and security factors. You must choose at least one: PIN or RSA Key. -

- -
+ +
- - -
More words = more security, harder to memorize
+ + +
+ 3 (33 bits) + 3 words (~33 bits) + 12 (132 bits) +
-
+
-
SECURITY FACTORS (select at least one)
+
SECURITY FACTORS (select at least one)
- -
-
-
- -