diff --git a/README.md b/README.md index 89f98ba..6a78226 100644 --- a/README.md +++ b/README.md @@ -5,7 +5,7 @@ A secure steganography system for hiding encrypted messages in images using hybr ![Python](https://img.shields.io/badge/Python-3.10--3.12-blue) ![License](https://img.shields.io/badge/License-MIT-green) ![Security](https://img.shields.io/badge/Security-AES--256--GCM-red) -![Version](https://img.shields.io/badge/Version-4.0.0-purple) +![Version](https://img.shields.io/badge/Version-4.0.1-purple) ## Features diff --git a/frontends/web/static/js/stegasoo.js b/frontends/web/static/js/stegasoo.js index d5d87d2..196c514 100644 --- a/frontends/web/static/js/stegasoo.js +++ b/frontends/web/static/js/stegasoo.js @@ -751,57 +751,41 @@ const Stegasoo = { /** * Initialize channel key UI for encode/decode pages * @param {Object} config - Configuration object - * @param {string} config.radioName - Name of radio buttons (default: 'channel_key') + * @param {string} config.selectId - ID of channel select dropdown * @param {string} config.customInputId - ID of custom key input container * @param {string} config.keyInputId - ID of key input field * @param {string} config.generateBtnId - ID of generate button (optional) - * @param {string} config.customRadioId - ID of custom radio button - * @param {string[]} config.cardIds - Array of card/label IDs for active class toggling */ initChannelKey(config = {}) { - const radioName = config.radioName || 'channel_key'; + const selectId = config.selectId || 'channelSelect'; const customInputId = config.customInputId || 'channelCustomInput'; const keyInputId = config.keyInputId || 'channelKeyInput'; const generateBtnId = config.generateBtnId; - const customRadioId = config.customRadioId || 'channelCustom'; - const cardIds = config.cardIds || []; - - const radios = document.querySelectorAll(`input[name="${radioName}"]`); + + const select = document.getElementById(selectId); const customInput = document.getElementById(customInputId); const keyInput = document.getElementById(keyInputId); const generateBtn = generateBtnId ? document.getElementById(generateBtnId) : null; - const customRadio = document.getElementById(customRadioId); - - // Toggle active class on mode-btn cards - const updateActiveState = () => { - radios.forEach(radio => { - const card = radio.closest('.mode-btn'); - if (card) { - card.classList.toggle('active', radio.checked); - } - }); - }; - + // Show/hide custom input based on selection - radios.forEach(radio => { - radio.addEventListener('change', () => { - updateActiveState(); - const isCustom = customRadio?.checked; - customInput?.classList.toggle('d-none', !isCustom); - if (isCustom && keyInput) { - keyInput.focus(); - } - }); - }); - + const updateVisibility = () => { + const isCustom = select?.value === 'custom'; + customInput?.classList.toggle('d-none', !isCustom); + if (isCustom && keyInput) { + keyInput.focus(); + } + }; + + select?.addEventListener('change', updateVisibility); + // Initial state - updateActiveState(); - + updateVisibility(); + // Format and validate key input keyInput?.addEventListener('input', () => { this.formatChannelKeyInput(keyInput); }); - + // Generate button (if present) generateBtn?.addEventListener('click', () => { if (keyInput) { @@ -810,26 +794,26 @@ const Stegasoo = { } }); }, - + /** * Handle form submission with channel key validation * @param {HTMLFormElement} form - Form element - * @param {string} customRadioId - ID of custom radio button + * @param {string} selectId - ID of channel select dropdown * @param {string} keyInputId - ID of key input field * @returns {boolean} True if valid, false to prevent submission */ - validateChannelKeyOnSubmit(form, customRadioId, keyInputId) { - const customRadio = document.getElementById(customRadioId); + validateChannelKeyOnSubmit(form, selectId, keyInputId) { + const select = document.getElementById(selectId); const keyInput = document.getElementById(keyInputId); - - if (customRadio?.checked && keyInput) { + + if (select?.value === 'custom' && keyInput) { if (!this.validateChannelKey(keyInput.value)) { keyInput.classList.add('is-invalid'); keyInput.focus(); return false; } - // Set the radio value to the actual key for form submission - customRadio.value = keyInput.value; + // Set the select value to the actual key for form submission + select.value = keyInput.value; } return true; }, @@ -880,20 +864,19 @@ const Stegasoo = { this.initCollapseChevrons(); this.initPassphraseFontResize(); - // Channel key (v4.0.0) - uses mode-btn style + // Channel key (v4.0.0) - uses select dropdown this.initChannelKey({ + selectId: 'channelSelect', customInputId: 'channelCustomInput', keyInputId: 'channelKeyInput', - generateBtnId: 'channelKeyGenerate', - customRadioId: 'channelCustom', - cardIds: ['channelAutoCard', 'channelPublicCard', 'channelCustomCard'] + generateBtnId: 'channelKeyGenerate' }); - + // Form submission with channel key validation const form = document.getElementById('encodeForm'); const btn = document.getElementById('encodeBtn'); form?.addEventListener('submit', (e) => { - if (!this.validateChannelKeyOnSubmit(form, 'channelCustom', 'channelKeyInput')) { + if (!this.validateChannelKeyOnSubmit(form, 'channelSelect', 'channelKeyInput')) { e.preventDefault(); return false; } @@ -913,19 +896,18 @@ const Stegasoo = { this.initCollapseChevrons(); this.initPassphraseFontResize(); - // Channel key (v4.0.0) - uses mode-btn style + // Channel key (v4.0.0) - uses select dropdown this.initChannelKey({ + selectId: 'channelSelectDec', customInputId: 'channelCustomInputDec', - keyInputId: 'channelKeyInputDec', - customRadioId: 'channelCustomDec', - cardIds: ['channelAutoCardDec', 'channelPublicCardDec', 'channelCustomCardDec'] + keyInputId: 'channelKeyInputDec' }); - + // Form submission with channel key validation and mode display const form = document.getElementById('decodeForm'); const btn = document.getElementById('decodeBtn'); form?.addEventListener('submit', (e) => { - if (!this.validateChannelKeyOnSubmit(form, 'channelCustomDec', 'channelKeyInputDec')) { + if (!this.validateChannelKeyOnSubmit(form, 'channelSelectDec', 'channelKeyInputDec')) { e.preventDefault(); return false; } diff --git a/frontends/web/static/style.css b/frontends/web/static/style.css index fa2572c..abd012e 100644 --- a/frontends/web/static/style.css +++ b/frontends/web/static/style.css @@ -1323,3 +1323,58 @@ footer { font-weight: 600; font-size: 0.5rem; } + +/* ---------------------------------------------------------------------------- + LED Indicator + ---------------------------------------------------------------------------- */ +.led-indicator { + display: inline-block; + width: 6px; + height: 6px; + border-radius: 50%; + vertical-align: middle; +} + +.led-yellow { + background: #fbbf24; + box-shadow: 0 0 3px #fbbf24, 0 0 6px rgba(251, 191, 36, 0.4); +} + +.led-green { + background: #22c55e; + box-shadow: 0 0 3px #22c55e, 0 0 6px rgba(34, 197, 94, 0.4); +} + +.led-red { + background: #ef4444; + box-shadow: 0 0 3px #ef4444, 0 0 6px rgba(239, 68, 68, 0.4); +} + +/* LED Badge backgrounds */ +.led-badge-yellow { + background: rgba(251, 191, 36, 0.2); + border: 1px solid rgba(251, 191, 36, 0.4); + color: #fbbf24; +} + +.led-badge-green { + background: rgba(34, 197, 94, 0.2); + border: 1px solid rgba(34, 197, 94, 0.4); + color: #22c55e; +} + +.led-badge-red { + background: rgba(239, 68, 68, 0.2); + border: 1px solid rgba(239, 68, 68, 0.4); + color: #ef4444; +} + +/* Key capsule container */ +.key-capsule { + display: inline-flex; + align-items: center; + border: 1px dashed rgba(255, 255, 255, 0.3); + border-radius: 0.375rem; + padding: 0.35rem 0.75rem; + background: rgba(0, 0, 0, 0.1); +} diff --git a/frontends/web/templates/decode.html b/frontends/web/templates/decode.html index 299175c..4e9da76 100644 --- a/frontends/web/templates/decode.html +++ b/frontends/web/templates/decode.html @@ -264,9 +264,71 @@ (provide same factors used during encoding) +
+
+ + + +
+ + + + + +
+ + +
+ +
+ + +
+
+ +
+ + Drop QR image or click to browse +
+ +
+ Original + Cropped QR + +
+
+ + RSA Key loaded +
+
+ RSA Key + -- +
+
+
+
+
+ + +
+ + +
+
+
+ +
-
+
@@ -277,121 +339,43 @@
If PIN was used during encoding
- +
-
+
- - -
- - - - - -
- - -
- -
- - -
-
- -
- - Drop QR image or click to browse -
- -
- Original - Cropped QR - -
-
- - RSA Key loaded -
-
- RSA Key - -- -
-
-
-
-
- - -
- - + + + + + {% if channel_configured %} +
+ + Server: {{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}
+ {% endif %}
- - -
-
- - -
- - - - - - - - -
- - - {% if channel_configured %} -
- - Server: {{ channel_fingerprint }} -
- {% endif %} - - -
-
- - +
+
+ +
+
-
+
+ + + + + +
+ + +
+ +
+ + +
+
+ +
+ + Drop QR image or click to browse +
+ +
+ Original + Cropped QR + +
+
+ + RSA Key loaded +
+
+ RSA Key + -- +
+
+
+
+
+ + +
+ + +
+
+
+ +
-
+
@@ -344,116 +406,39 @@
Static 6-9 digit PIN
- +
-
+
- - -
- - - - - -
- - -
- -
- - -
-
- -
- - Drop QR image or click to browse -
- -
- Original - Cropped QR - -
-
- - RSA Key loaded -
-
- RSA Key - -- -
-
-
-
-
- - -
- - + + + + + {% if channel_configured %} +
+ + Server: {{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}
+ {% endif %}
- - -
-
- - -
- - - - - - - - -
- - - {% if channel_configured %} -
- - Server: {{ channel_fingerprint }} -
- {% endif %} - - -
-
- - +
+
+ +
+
-
diff --git a/frontends/web/templates/index.html b/frontends/web/templates/index.html index 9312e2c..54a533b 100644 --- a/frontends/web/templates/index.html +++ b/frontends/web/templates/index.html @@ -25,10 +25,12 @@
- Private Channel Active - Messages are isolated to this deployment + Private Channel Mode +
+
+ Key Loaded + {{ channel_fingerprint }}
- {{ channel_fingerprint }}
{% endif %} diff --git a/pyproject.toml b/pyproject.toml index 47a1c85..585f135 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -4,7 +4,7 @@ build-backend = "hatchling.build" [project] name = "stegasoo" -version = "4.0.0" +version = "4.0.1" description = "Secure steganography with hybrid photo + passphrase + PIN authentication" readme = "README.md" license = "MIT" diff --git a/src/stegasoo/__init__.py b/src/stegasoo/__init__.py index c95e580..5e25d3d 100644 --- a/src/stegasoo/__init__.py +++ b/src/stegasoo/__init__.py @@ -1,5 +1,5 @@ """ -Stegasoo - Secure Steganography with Multi-Factor Authentication (v4.0.0) +Stegasoo - Secure Steganography with Multi-Factor Authentication (v4.0.1) Changes in v4.0.0: - Added channel key support for deployment/group isolation @@ -7,11 +7,11 @@ Changes in v4.0.0: - encode() and decode() now accept channel_key parameter """ -__version__ = "4.0.0" +__version__ = "4.0.1" # Core functionality from .encode import encode -from .decode import decode, decode_file +from .decode import decode, decode_file, decode_text # Credential generation from .generate import ( @@ -153,11 +153,12 @@ DCT_BYTES_PER_PIXEL = 0.125 __all__ = [ # Version "__version__", - + # Core "encode", "decode", "decode_file", + "decode_text", # Generation "generate_pin", diff --git a/src/stegasoo/constants.py b/src/stegasoo/constants.py index 8402214..220befe 100644 --- a/src/stegasoo/constants.py +++ b/src/stegasoo/constants.py @@ -1,5 +1,5 @@ """ -Stegasoo Constants and Configuration (v4.0.0 - Channel Key Support) +Stegasoo Constants and Configuration (v4.0.1 - Channel Key Support) Central location for all magic numbers, limits, and crypto parameters. All version numbers, limits, and configuration values should be defined here. @@ -21,7 +21,7 @@ from pathlib import Path # VERSION # ============================================================================ -__version__ = "4.0.0" +__version__ = "4.0.1" # ============================================================================ # FILE FORMAT diff --git a/src/stegasoo/crypto.py b/src/stegasoo/crypto.py index 402ff75..34ec264 100644 --- a/src/stegasoo/crypto.py +++ b/src/stegasoo/crypto.py @@ -624,12 +624,15 @@ def get_active_channel_key() -> Optional[str]: return get_channel_key() -def get_channel_fingerprint() -> Optional[str]: +def get_channel_fingerprint(key: Optional[str] = None) -> Optional[str]: """ - Get a display-safe fingerprint of the configured channel key. - + Get a display-safe fingerprint of a channel key. + + Args: + key: Channel key (if None, uses configured key) + Returns: Masked key like "ABCD-••••-••••-••••-••••-••••-••••-3456" or None """ from .channel import get_channel_fingerprint as _get_fingerprint - return _get_fingerprint() + return _get_fingerprint(key) diff --git a/src/stegasoo/steganography.py b/src/stegasoo/steganography.py index 9a55bc4..580f705 100644 --- a/src/stegasoo/steganography.py +++ b/src/stegasoo/steganography.py @@ -56,23 +56,24 @@ EXT_TO_FORMAT = { } # ============================================================================= -# OVERHEAD CONSTANTS (v3.2.0 - Updated for date-independent format) +# OVERHEAD CONSTANTS (v4.0.0 - Updated for channel key support) # ============================================================================= -# v3.2.0 Header format (no date field): +# v4.0.0 Header format (with flags byte for channel key indicator): # Magic: 4 bytes (\x89ST3) -# Version: 1 byte (4 for v3.2.0) +# Version: 1 byte (5 for v4.0.0) +# Flags: 1 byte (bit 0 = has channel key) # Salt: 32 bytes # IV: 12 bytes # Tag: 16 bytes # ----------------- -# Total: 65 bytes +# Total: 66 bytes # -# Previous v3.1.0 had date field (10 bytes + 1 byte length) = 76 bytes header -# The old value of 104 was incorrect even for v3.1.0 +# 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 = 65 # v3.2.0: Magic + version + salt + iv + tag +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 # 69 bytes total +ENCRYPTION_OVERHEAD = HEADER_OVERHEAD + LENGTH_PREFIX # 70 bytes total # DCT output format options (v3.0.1) DCT_OUTPUT_PNG = 'png' diff --git a/tests/test_stegasoo.py b/tests/test_stegasoo.py index 8b9db43..f8ae03b 100644 --- a/tests/test_stegasoo.py +++ b/tests/test_stegasoo.py @@ -1,11 +1,13 @@ """ Stegasoo Tests (v4.0.0) -Tests for key generation, validation, encoding/decoding, and output formats. +Tests for key generation, validation, encoding/decoding, output formats, +and channel key functionality. Updated for v4.0.0: - Same API as v3.2.0 (passphrase, no date_str) -- JPEG normalization for jpegio compatibility +- Channel key support for deployment/group isolation +- HEADER_OVERHEAD increased to 66 bytes (flags byte added) - Python 3.12 recommended (3.13 not supported) """ @@ -16,17 +18,20 @@ import io import stegasoo from stegasoo import ( generate_pin, - generate_phrase, + generate_passphrase, generate_credentials, validate_pin, validate_message, validate_passphrase, + validate_channel_key, encode, decode, decode_text, + generate_channel_key, + get_channel_fingerprint, __version__, ) -from stegasoo.steganography import get_output_format, HEADER_OVERHEAD +from stegasoo.steganography import get_output_format # ============================================================================= @@ -104,16 +109,16 @@ class TestKeygen: assert len(pin) == length assert pin.isdigit() - def test_generate_phrase_default(self): - """Default phrase should have 4 words (v3.2.0 change).""" - phrase = generate_phrase() + def test_generate_passphrase_default(self): + """Default passphrase should have 4 words (v3.2.0 change).""" + phrase = generate_passphrase() words = phrase.split() assert len(words) == 4 # Changed from 3 in v3.1.x - def test_generate_phrase_custom_length(self): - """Phrase generation should work for custom lengths.""" + def test_generate_passphrase_custom_length(self): + """Passphrase generation should work for custom lengths.""" for length in [3, 4, 5, 6, 8, 12]: - phrase = generate_phrase(length) + phrase = generate_passphrase(length) words = phrase.split() assert len(words) == length @@ -287,19 +292,20 @@ class TestOutputFormat: # ============================================================================= -# Header Overhead Test (v3.2.0) +# Header Overhead Test (v4.0.0) # ============================================================================= class TestConstants: """Tests for constants and configuration.""" - + def test_header_overhead_value(self): - """Header overhead should be 65 bytes (v3.2.0 fix).""" - assert HEADER_OVERHEAD == 65 + """Header overhead should be 66 bytes (v4.0.0: added flags byte).""" + from stegasoo.steganography import HEADER_OVERHEAD + assert HEADER_OVERHEAD == 66 # ============================================================================= -# Encode/Decode Tests (v3.2.0 Updated) +# Encode/Decode Tests (v4.0.0 Updated) # ============================================================================= class TestEncodeDecode: @@ -474,8 +480,8 @@ class TestEncodeDecode: assert decoded.message == message - def test_filename_has_no_date(self, png_image): - """v3.2.0: Output filename should not have date suffix.""" + def test_filename_format(self, png_image): + """Output filename should have random hex and date suffix.""" result = encode( message="Test", reference_photo=png_image, @@ -483,10 +489,10 @@ class TestEncodeDecode: passphrase="test phrase here now", pin="123456" ) - # Filename should be like "a1b2c3d4.png", not "a1b2c3d4_20251227.png" - # Check that there's no underscore followed by 8 digits + # Filename format: {random_hex}_{YYYYMMDD}.{ext} + # e.g., "a1b2c3d4_20251227.png" import re - assert not re.search(r'_\d{8}\.', result.filename) + assert re.search(r'^[a-f0-9]{8}_\d{8}\.png$', result.filename) # ============================================================================= @@ -605,3 +611,155 @@ class TestBackwardCompatibility: pin="123456", date_str="2025-01-01" # Removed parameter ) + + +# ============================================================================= +# Channel Key Tests (v4.0.0) +# ============================================================================= + +class TestChannelKey: + """Tests for channel key functionality (v4.0.0).""" + + def test_generate_channel_key_format(self): + """Generated channel key should have correct format.""" + key = generate_channel_key() + # Format: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX (8 groups of 4) + assert len(key) == 39 + parts = key.split('-') + assert len(parts) == 8 + for part in parts: + assert len(part) == 4 + assert part.isalnum() + + def test_validate_channel_key_valid(self): + """Valid channel key should pass validation.""" + key = generate_channel_key() + assert validate_channel_key(key) + + def test_validate_channel_key_invalid(self): + """Invalid channel key should fail validation.""" + assert not validate_channel_key("") + assert not validate_channel_key("invalid") + assert not validate_channel_key("ABCD-1234") # Too short + + def test_channel_fingerprint_format(self): + """Channel fingerprint should mask middle sections.""" + key = "ABCD-1234-EFGH-5678-IJKL-9012-MNOP-3456" + fingerprint = get_channel_fingerprint(key) + assert fingerprint is not None + # First and last groups visible, middle masked + assert fingerprint.startswith("ABCD-") + assert fingerprint.endswith("-3456") + assert "••••" in fingerprint + + def test_encode_decode_with_channel_key(self, png_image): + """Encode/decode should work with explicit channel key.""" + message = "Secret with channel key!" + passphrase = "apple forest thunder mountain" + pin = "123456" + channel_key = generate_channel_key() + + # Encode with channel key + result = encode( + message=message, + reference_photo=png_image, + carrier_image=png_image, + passphrase=passphrase, + pin=pin, + channel_key=channel_key + ) + + assert result.stego_image is not None + + # Decode with same channel key + decoded = decode( + stego_image=result.stego_image, + reference_photo=png_image, + passphrase=passphrase, + pin=pin, + channel_key=channel_key + ) + + assert decoded.message == message + + def test_decode_wrong_channel_key_fails(self, png_image): + """Decoding with wrong channel key should fail.""" + message = "Secret message" + passphrase = "apple forest thunder mountain" + pin = "123456" + channel_key1 = generate_channel_key() + channel_key2 = generate_channel_key() + + # Encode with one channel key + result = encode( + message=message, + reference_photo=png_image, + carrier_image=png_image, + passphrase=passphrase, + pin=pin, + channel_key=channel_key1 + ) + + # Decode with different channel key should fail + with pytest.raises((stegasoo.DecryptionError, stegasoo.ExtractionError)): + decode( + stego_image=result.stego_image, + reference_photo=png_image, + passphrase=passphrase, + pin=pin, + channel_key=channel_key2 + ) + + def test_encode_decode_public_mode(self, png_image): + """Encode/decode should work without channel key (public mode).""" + message = "Public message!" + passphrase = "apple forest thunder mountain" + pin = "123456" + + # Encode without channel key (explicit public mode) + result = encode( + message=message, + reference_photo=png_image, + carrier_image=png_image, + passphrase=passphrase, + pin=pin, + channel_key="" # Explicit public mode + ) + + # Decode without channel key + decoded = decode( + stego_image=result.stego_image, + reference_photo=png_image, + passphrase=passphrase, + pin=pin, + channel_key="" # Explicit public mode + ) + + assert decoded.message == message + + def test_channel_key_mismatch_public_vs_private(self, png_image): + """Decoding public message with channel key should fail.""" + message = "Public message" + passphrase = "apple forest thunder mountain" + pin = "123456" + + # Encode without channel key (public) + result = encode( + message=message, + reference_photo=png_image, + carrier_image=png_image, + passphrase=passphrase, + pin=pin, + channel_key="" # Public mode + ) + + # Decode with channel key should fail + channel_key = generate_channel_key() + with pytest.raises((stegasoo.DecryptionError, stegasoo.ExtractionError)): + decode( + stego_image=result.stego_image, + reference_photo=png_image, + passphrase=passphrase, + pin=pin, + channel_key=channel_key + )