Bump version to 4.0.1 with Web UI improvements

- Update version to 4.0.1 across constants.py, __init__.py, pyproject.toml, README
- Refactor channel key UI from radio buttons to select dropdown
- Add LED indicator and key capsule CSS styles
- Reorganize encode/decode forms: RSA key section moved up, PIN + Channel in row
- Streamline channel key JavaScript for dropdown-based selection

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Aaron D. Lee
2026-01-02 16:43:25 -05:00
parent 6fa4b447db
commit d94ee7be90
12 changed files with 477 additions and 307 deletions

View File

@@ -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) ![Python](https://img.shields.io/badge/Python-3.10--3.12-blue)
![License](https://img.shields.io/badge/License-MIT-green) ![License](https://img.shields.io/badge/License-MIT-green)
![Security](https://img.shields.io/badge/Security-AES--256--GCM-red) ![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 ## Features

View File

@@ -751,51 +751,35 @@ const Stegasoo = {
/** /**
* Initialize channel key UI for encode/decode pages * Initialize channel key UI for encode/decode pages
* @param {Object} config - Configuration object * @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.customInputId - ID of custom key input container
* @param {string} config.keyInputId - ID of key input field * @param {string} config.keyInputId - ID of key input field
* @param {string} config.generateBtnId - ID of generate button (optional) * @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 = {}) { initChannelKey(config = {}) {
const radioName = config.radioName || 'channel_key'; const selectId = config.selectId || 'channelSelect';
const customInputId = config.customInputId || 'channelCustomInput'; const customInputId = config.customInputId || 'channelCustomInput';
const keyInputId = config.keyInputId || 'channelKeyInput'; const keyInputId = config.keyInputId || 'channelKeyInput';
const generateBtnId = config.generateBtnId; 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 customInput = document.getElementById(customInputId);
const keyInput = document.getElementById(keyInputId); const keyInput = document.getElementById(keyInputId);
const generateBtn = generateBtnId ? document.getElementById(generateBtnId) : null; 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 // Show/hide custom input based on selection
radios.forEach(radio => { const updateVisibility = () => {
radio.addEventListener('change', () => { const isCustom = select?.value === 'custom';
updateActiveState(); customInput?.classList.toggle('d-none', !isCustom);
const isCustom = customRadio?.checked; if (isCustom && keyInput) {
customInput?.classList.toggle('d-none', !isCustom); keyInput.focus();
if (isCustom && keyInput) { }
keyInput.focus(); };
}
}); select?.addEventListener('change', updateVisibility);
});
// Initial state // Initial state
updateActiveState(); updateVisibility();
// Format and validate key input // Format and validate key input
keyInput?.addEventListener('input', () => { keyInput?.addEventListener('input', () => {
@@ -814,22 +798,22 @@ const Stegasoo = {
/** /**
* Handle form submission with channel key validation * Handle form submission with channel key validation
* @param {HTMLFormElement} form - Form element * @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 * @param {string} keyInputId - ID of key input field
* @returns {boolean} True if valid, false to prevent submission * @returns {boolean} True if valid, false to prevent submission
*/ */
validateChannelKeyOnSubmit(form, customRadioId, keyInputId) { validateChannelKeyOnSubmit(form, selectId, keyInputId) {
const customRadio = document.getElementById(customRadioId); const select = document.getElementById(selectId);
const keyInput = document.getElementById(keyInputId); const keyInput = document.getElementById(keyInputId);
if (customRadio?.checked && keyInput) { if (select?.value === 'custom' && keyInput) {
if (!this.validateChannelKey(keyInput.value)) { if (!this.validateChannelKey(keyInput.value)) {
keyInput.classList.add('is-invalid'); keyInput.classList.add('is-invalid');
keyInput.focus(); keyInput.focus();
return false; return false;
} }
// Set the radio value to the actual key for form submission // Set the select value to the actual key for form submission
customRadio.value = keyInput.value; select.value = keyInput.value;
} }
return true; return true;
}, },
@@ -880,20 +864,19 @@ const Stegasoo = {
this.initCollapseChevrons(); this.initCollapseChevrons();
this.initPassphraseFontResize(); this.initPassphraseFontResize();
// Channel key (v4.0.0) - uses mode-btn style // Channel key (v4.0.0) - uses select dropdown
this.initChannelKey({ this.initChannelKey({
selectId: 'channelSelect',
customInputId: 'channelCustomInput', customInputId: 'channelCustomInput',
keyInputId: 'channelKeyInput', keyInputId: 'channelKeyInput',
generateBtnId: 'channelKeyGenerate', generateBtnId: 'channelKeyGenerate'
customRadioId: 'channelCustom',
cardIds: ['channelAutoCard', 'channelPublicCard', 'channelCustomCard']
}); });
// Form submission with channel key validation // Form submission with channel key validation
const form = document.getElementById('encodeForm'); const form = document.getElementById('encodeForm');
const btn = document.getElementById('encodeBtn'); const btn = document.getElementById('encodeBtn');
form?.addEventListener('submit', (e) => { form?.addEventListener('submit', (e) => {
if (!this.validateChannelKeyOnSubmit(form, 'channelCustom', 'channelKeyInput')) { if (!this.validateChannelKeyOnSubmit(form, 'channelSelect', 'channelKeyInput')) {
e.preventDefault(); e.preventDefault();
return false; return false;
} }
@@ -913,19 +896,18 @@ const Stegasoo = {
this.initCollapseChevrons(); this.initCollapseChevrons();
this.initPassphraseFontResize(); this.initPassphraseFontResize();
// Channel key (v4.0.0) - uses mode-btn style // Channel key (v4.0.0) - uses select dropdown
this.initChannelKey({ this.initChannelKey({
selectId: 'channelSelectDec',
customInputId: 'channelCustomInputDec', customInputId: 'channelCustomInputDec',
keyInputId: 'channelKeyInputDec', keyInputId: 'channelKeyInputDec'
customRadioId: 'channelCustomDec',
cardIds: ['channelAutoCardDec', 'channelPublicCardDec', 'channelCustomCardDec']
}); });
// Form submission with channel key validation and mode display // Form submission with channel key validation and mode display
const form = document.getElementById('decodeForm'); const form = document.getElementById('decodeForm');
const btn = document.getElementById('decodeBtn'); const btn = document.getElementById('decodeBtn');
form?.addEventListener('submit', (e) => { form?.addEventListener('submit', (e) => {
if (!this.validateChannelKeyOnSubmit(form, 'channelCustomDec', 'channelKeyInputDec')) { if (!this.validateChannelKeyOnSubmit(form, 'channelSelectDec', 'channelKeyInputDec')) {
e.preventDefault(); e.preventDefault();
return false; return false;
} }

View File

@@ -1323,3 +1323,58 @@ footer {
font-weight: 600; font-weight: 600;
font-size: 0.5rem; 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);
}

View File

@@ -264,9 +264,71 @@
<span class="text-warning small">(provide same factors used during encoding)</span> <span class="text-warning small">(provide same factors used during encoding)</span>
</h6> </h6>
<div class="mb-3">
<div class="security-box">
<label class="form-label">
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
</label>
<!-- RSA Input Method Toggle -->
<div class="btn-group w-100 mb-2" role="group">
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodFile" value="file" checked>
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodFile">
<i class="bi bi-file-earmark me-1"></i>.pem File
</label>
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodQr" value="qr">
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodQr">
<i class="bi bi-qr-code me-1"></i>QR Code
</label>
</div>
<!-- .pem File Input -->
<div id="rsaFileSection">
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
</div>
<!-- QR Code Input -->
<div id="rsaQrSection" class="d-none">
<div class="drop-zone p-3" id="qrDropZone">
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaKeyQrInput">
<div class="drop-zone-label text-center">
<i class="bi bi-qr-code-scan fs-4 d-block text-muted mb-1"></i>
<span class="text-muted small">Drop QR image or click to browse</span>
</div>
<!-- Crop animation container -->
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
<img class="qr-original" id="qrOriginal" alt="Original">
<img class="qr-cropped" id="qrCropped" alt="Cropped QR">
<!-- Data panel -->
<div class="qr-data-panel">
<div class="qr-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span>RSA Key loaded</span>
</div>
<div class="qr-data-row">
<span class="qr-status-badge">RSA Key</span>
<span class="qr-data-value">--</span>
</div>
</div>
</div>
</div>
</div>
<!-- Key Password (always visible) -->
<div class="input-group input-group-sm mt-2">
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="rsaPasswordInput">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
</div>
<!-- PIN + Channel Row -->
<div class="row"> <div class="row">
<div class="col-md-4 mb-3"> <div class="col-md-4 mb-3">
<div class="security-box"> <div class="security-box h-100">
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label> <label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
<div class="input-group pin-input-container"> <div class="input-group pin-input-container">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9" style="max-width: 180px;"> <input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9" style="max-width: 180px;">
@@ -279,119 +341,41 @@
</div> </div>
<div class="col-md-8 mb-3"> <div class="col-md-8 mb-3">
<div class="security-box"> <div class="security-box h-100">
<label class="form-label"> <label class="form-label">
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key <i class="bi bi-broadcast me-1"></i> Channel
<span class="badge bg-info ms-1">v4.0</span>
<a href="/about#channel-keys" class="text-muted ms-1" title="Learn about channels"><i class="bi bi-info-circle"></i></a>
</label> </label>
<!-- RSA Input Method Toggle --> <select class="form-select" name="channel_key" id="channelSelectDec">
<div class="btn-group w-100 mb-2" role="group"> <option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</option>
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodFile" value="file" checked> <option value="none">Public</option>
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodFile"> <option value="custom">Custom</option>
<i class="bi bi-file-earmark me-1"></i>.pem File </select>
</label>
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodQr" value="qr"> <!-- Server channel indicator (compact) -->
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodQr"> {% if channel_configured %}
<i class="bi bi-qr-code me-1"></i>QR Code <div class="small text-success mt-2">
</label> <i class="bi bi-shield-lock me-1"></i>
</div> Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
<!-- .pem File Input -->
<div id="rsaFileSection">
<input type="file" name="rsa_key" class="form-control form-control-sm" id="rsaKeyInput" accept=".pem,.key,application/x-pem-file">
</div>
<!-- QR Code Input -->
<div id="rsaQrSection" class="d-none">
<div class="drop-zone p-3" id="qrDropZone">
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaKeyQrInput">
<div class="drop-zone-label text-center">
<i class="bi bi-qr-code-scan fs-4 d-block text-muted mb-1"></i>
<span class="text-muted small">Drop QR image or click to browse</span>
</div>
<!-- Crop animation container -->
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
<img class="qr-original" id="qrOriginal" alt="Original">
<img class="qr-cropped" id="qrCropped" alt="Cropped QR">
<!-- Data panel -->
<div class="qr-data-panel">
<div class="qr-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span>RSA Key loaded</span>
</div>
<div class="qr-data-row">
<span class="qr-status-badge">RSA Key</span>
<span class="qr-data-value">--</span>
</div>
</div>
</div>
</div>
</div>
<!-- Key Password (always visible) -->
<div class="input-group input-group-sm mt-2">
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="rsaPasswordInput">
<i class="bi bi-eye"></i>
</button>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
<!-- ================================================================ <!-- Custom Channel Key Input (shown when Custom selected) -->
CHANNEL KEY (v4.0.0) - Deployment/Group Isolation <div class="mb-4 d-none" id="channelCustomInputDec">
================================================================ --> <div class="security-box">
<div class="mb-4"> <label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
<div class="security-box"> <div class="input-group">
<label class="form-label">
<i class="bi bi-broadcast me-1"></i> Channel
<span class="badge bg-info ms-1">v4.0</span>
</label>
<div class="d-flex gap-2">
<!-- Auto Mode -->
<label class="mode-btn flex-fill {% if channel_configured %}active{% endif %}" id="channelAutoCardDec" for="channelAutoDec">
<input class="form-check-input" type="radio" name="channel_key" id="channelAutoDec" value="auto" checked>
<i class="bi bi-gear-fill {% if channel_configured %}text-success{% else %}text-secondary{% endif %} ms-2"></i>
<span class="ms-2"><strong>Auto</strong> <span class="text-muted d-none d-sm-inline">· {% if channel_configured %}Server Key{% else %}Public{% endif %}</span></span>
</label>
<!-- Public Mode -->
<label class="mode-btn flex-fill" id="channelPublicCardDec" for="channelPublicDec">
<input class="form-check-input" type="radio" name="channel_key" id="channelPublicDec" value="none">
<i class="bi bi-globe text-info ms-2"></i>
<span class="ms-2"><strong>Public</strong> <span class="text-muted d-none d-sm-inline">· No key</span></span>
</label>
<!-- Custom Key -->
<label class="mode-btn flex-fill" id="channelCustomCardDec" for="channelCustomDec">
<input class="form-check-input" type="radio" name="channel_key" id="channelCustomDec" value="custom">
<i class="bi bi-key-fill text-warning ms-2"></i>
<span class="ms-2"><strong>Custom</strong></span>
</label>
</div>
<!-- Server channel indicator (compact) -->
{% if channel_configured %}
<div class="small text-success mt-2">
<i class="bi bi-shield-lock me-1"></i>
Server: <code>{{ channel_fingerprint }}</code>
</div>
{% endif %}
<!-- Custom key input -->
<div class="mt-2 d-none" id="channelCustomInputDec">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="text" name="channel_key_custom" class="form-control font-monospace" <input type="text" name="channel_key_custom" class="form-control font-monospace"
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}" pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}"
id="channelKeyInputDec"> id="channelKeyInputDec">
</div> </div>
</div> </div>
</div>
</div> </div>
<!-- ================================================================ <!-- ================================================================

View File

@@ -331,9 +331,71 @@
<span class="text-warning small">(provide at least one: PIN or RSA Key)</span> <span class="text-warning small">(provide at least one: PIN or RSA Key)</span>
</h6> </h6>
<div class="mb-3">
<div class="security-box">
<label class="form-label">
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key
</label>
<!-- RSA Input Method Toggle -->
<div class="btn-group w-100 mb-2" role="group">
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodFile" value="file" checked>
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodFile">
<i class="bi bi-file-earmark me-1"></i>.pem File
</label>
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodQr" value="qr">
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodQr">
<i class="bi bi-qr-code me-1"></i>QR Code
</label>
</div>
<!-- .pem File Input -->
<div id="rsaFileSection">
<input type="file" name="rsa_key" class="form-control form-control-sm" accept=".pem">
</div>
<!-- QR Code Input -->
<div id="rsaQrSection" class="d-none">
<div class="drop-zone p-3" id="qrDropZone">
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaQrInput">
<div class="drop-zone-label text-center">
<i class="bi bi-qr-code-scan fs-4 d-block text-muted mb-1"></i>
<span class="text-muted small">Drop QR image or click to browse</span>
</div>
<!-- Crop animation container -->
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
<img class="qr-original" id="qrOriginal" alt="Original">
<img class="qr-cropped" id="qrCropped" alt="Cropped QR">
<!-- Data panel -->
<div class="qr-data-panel">
<div class="qr-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span>RSA Key loaded</span>
</div>
<div class="qr-data-row">
<span class="qr-status-badge">RSA Key</span>
<span class="qr-data-value">--</span>
</div>
</div>
</div>
</div>
</div>
<!-- Key Password (always visible) -->
<div class="input-group input-group-sm mt-2">
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="rsaPasswordInput">
<i class="bi bi-eye"></i>
</button>
</div>
</div>
</div>
<!-- PIN + Channel Row -->
<div class="row"> <div class="row">
<div class="col-md-4 mb-3"> <div class="col-md-4 mb-3">
<div class="security-box"> <div class="security-box h-100">
<label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label> <label class="form-label"><i class="bi bi-123 me-1"></i> PIN</label>
<div class="input-group pin-input-container"> <div class="input-group pin-input-container">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9" style="max-width: 180px;"> <input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9" style="max-width: 180px;">
@@ -346,112 +408,35 @@
</div> </div>
<div class="col-md-8 mb-3"> <div class="col-md-8 mb-3">
<div class="security-box"> <div class="security-box h-100">
<label class="form-label"> <label class="form-label">
<i class="bi bi-file-earmark-lock me-1"></i> RSA Key <i class="bi bi-broadcast me-1"></i> Channel
<span class="badge bg-info ms-1">v4.0</span>
<a href="/about#channel-keys" class="text-muted ms-1" title="Learn about channels"><i class="bi bi-info-circle"></i></a>
</label> </label>
<!-- RSA Input Method Toggle --> <select class="form-select" name="channel_key" id="channelSelect">
<div class="btn-group w-100 mb-2" role="group"> <option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</option>
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodFile" value="file" checked> <option value="none">Public</option>
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodFile"> <option value="custom">Custom</option>
<i class="bi bi-file-earmark me-1"></i>.pem File </select>
</label>
<input type="radio" class="btn-check" name="rsa_input_method" id="rsaMethodQr" value="qr"> <!-- Server channel indicator (compact) -->
<label class="btn btn-outline-secondary btn-sm" for="rsaMethodQr"> {% if channel_configured %}
<i class="bi bi-qr-code me-1"></i>QR Code <div class="small text-success mt-2" id="channelServerInfo">
</label> <i class="bi bi-shield-lock me-1"></i>
</div> Server: <code>{{ channel_fingerprint[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
<!-- .pem File Input -->
<div id="rsaFileSection">
<input type="file" name="rsa_key" class="form-control form-control-sm" accept=".pem">
</div>
<!-- QR Code Input -->
<div id="rsaQrSection" class="d-none">
<div class="drop-zone p-3" id="qrDropZone">
<input type="file" name="rsa_key_qr" accept="image/*" id="rsaQrInput">
<div class="drop-zone-label text-center">
<i class="bi bi-qr-code-scan fs-4 d-block text-muted mb-1"></i>
<span class="text-muted small">Drop QR image or click to browse</span>
</div>
<!-- Crop animation container -->
<div class="qr-scan-container qr-crop-container d-none" id="qrCropContainer">
<img class="qr-original" id="qrOriginal" alt="Original">
<img class="qr-cropped" id="qrCropped" alt="Cropped QR">
<!-- Data panel -->
<div class="qr-data-panel">
<div class="qr-data-filename">
<i class="bi bi-check-circle-fill"></i>
<span>RSA Key loaded</span>
</div>
<div class="qr-data-row">
<span class="qr-status-badge">RSA Key</span>
<span class="qr-data-value">--</span>
</div>
</div>
</div>
</div>
</div>
<!-- Key Password (always visible) -->
<div class="input-group input-group-sm mt-2">
<input type="password" name="rsa_password" class="form-control" id="rsaPasswordInput" placeholder="Key password (if encrypted)">
<button class="btn btn-outline-secondary" type="button" data-toggle-password="rsaPasswordInput">
<i class="bi bi-eye"></i>
</button>
</div> </div>
{% endif %}
</div> </div>
</div> </div>
</div> </div>
<!-- ================================================================ <!-- Custom Channel Key Input (shown when Custom selected) -->
CHANNEL KEY (v4.0.0) - Deployment/Group Isolation <div class="mb-4 d-none" id="channelCustomInput">
================================================================ --> <div class="security-box">
<div class="mb-4"> <label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
<div class="security-box"> <div class="input-group">
<label class="form-label">
<i class="bi bi-broadcast me-1"></i> Channel
<span class="badge bg-info ms-1">v4.0</span>
</label>
<div class="d-flex gap-2">
<!-- Auto Mode -->
<label class="mode-btn flex-fill {% if channel_configured %}active{% endif %}" id="channelAutoCard" for="channelAuto">
<input class="form-check-input" type="radio" name="channel_key" id="channelAuto" value="auto" checked>
<i class="bi bi-gear-fill {% if channel_configured %}text-success{% else %}text-secondary{% endif %} ms-2"></i>
<span class="ms-2"><strong>Auto</strong> <span class="text-muted d-none d-sm-inline">· {% if channel_configured %}Server Key{% else %}Public{% endif %}</span></span>
</label>
<!-- Public Mode -->
<label class="mode-btn flex-fill" id="channelPublicCard" for="channelPublic">
<input class="form-check-input" type="radio" name="channel_key" id="channelPublic" value="none">
<i class="bi bi-globe text-info ms-2"></i>
<span class="ms-2"><strong>Public</strong> <span class="text-muted d-none d-sm-inline">· No key</span></span>
</label>
<!-- Custom Key -->
<label class="mode-btn flex-fill" id="channelCustomCard" for="channelCustom">
<input class="form-check-input" type="radio" name="channel_key" id="channelCustom" value="custom">
<i class="bi bi-key-fill text-warning ms-2"></i>
<span class="ms-2"><strong>Custom</strong></span>
</label>
</div>
<!-- Server channel indicator (compact) -->
{% if channel_configured %}
<div class="small text-success mt-2" id="channelServerInfo">
<i class="bi bi-shield-lock me-1"></i>
Server: <code>{{ channel_fingerprint }}</code>
</div>
{% endif %}
<!-- Custom key input -->
<div class="mt-2 d-none" id="channelCustomInput">
<div class="input-group input-group-sm">
<span class="input-group-text"><i class="bi bi-key"></i></span>
<input type="text" name="channel_key_custom" class="form-control font-monospace" <input type="text" name="channel_key_custom" class="form-control font-monospace"
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX" placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}" pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}"
@@ -464,7 +449,6 @@
Invalid format. Use: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX Invalid format. Use: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
</div> </div>
</div> </div>
</div>
</div> </div>
<!-- Advanced Options (DCT sub-options only) --> <!-- Advanced Options (DCT sub-options only) -->

View File

@@ -25,10 +25,12 @@
<div class="d-flex align-items-center justify-content-between"> <div class="d-flex align-items-center justify-content-between">
<div> <div>
<i class="bi bi-shield-lock me-2"></i> <i class="bi bi-shield-lock me-2"></i>
<strong>Private Channel Active</strong> <strong>Private Channel Mode</strong>
<span class="text-muted ms-2">Messages are isolated to this deployment</span> </div>
<div class="key-capsule">
<span class="badge led-badge-yellow"><span class="led-indicator led-yellow me-1"></span>Key Loaded</span>
<code class="small ms-2">{{ channel_fingerprint }}</code>
</div> </div>
<code class="small">{{ channel_fingerprint }}</code>
</div> </div>
</div> </div>
{% endif %} {% endif %}

View File

@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
[project] [project]
name = "stegasoo" name = "stegasoo"
version = "4.0.0" version = "4.0.1"
description = "Secure steganography with hybrid photo + passphrase + PIN authentication" description = "Secure steganography with hybrid photo + passphrase + PIN authentication"
readme = "README.md" readme = "README.md"
license = "MIT" license = "MIT"

View File

@@ -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: Changes in v4.0.0:
- Added channel key support for deployment/group isolation - 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 - encode() and decode() now accept channel_key parameter
""" """
__version__ = "4.0.0" __version__ = "4.0.1"
# Core functionality # Core functionality
from .encode import encode from .encode import encode
from .decode import decode, decode_file from .decode import decode, decode_file, decode_text
# Credential generation # Credential generation
from .generate import ( from .generate import (
@@ -158,6 +158,7 @@ __all__ = [
"encode", "encode",
"decode", "decode",
"decode_file", "decode_file",
"decode_text",
# Generation # Generation
"generate_pin", "generate_pin",

View File

@@ -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. Central location for all magic numbers, limits, and crypto parameters.
All version numbers, limits, and configuration values should be defined here. All version numbers, limits, and configuration values should be defined here.
@@ -21,7 +21,7 @@ from pathlib import Path
# VERSION # VERSION
# ============================================================================ # ============================================================================
__version__ = "4.0.0" __version__ = "4.0.1"
# ============================================================================ # ============================================================================
# FILE FORMAT # FILE FORMAT

View File

@@ -624,12 +624,15 @@ def get_active_channel_key() -> Optional[str]:
return get_channel_key() 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: Returns:
Masked key like "ABCD-••••-••••-••••-••••-••••-••••-3456" or None Masked key like "ABCD-••••-••••-••••-••••-••••-••••-3456" or None
""" """
from .channel import get_channel_fingerprint as _get_fingerprint from .channel import get_channel_fingerprint as _get_fingerprint
return _get_fingerprint() return _get_fingerprint(key)

View File

@@ -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) # 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 # Salt: 32 bytes
# IV: 12 bytes # IV: 12 bytes
# Tag: 16 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 # v3.2.0 had 65 bytes (no flags byte)
# The old value of 104 was incorrect even for v3.1.0 # 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 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 format options (v3.0.1)
DCT_OUTPUT_PNG = 'png' DCT_OUTPUT_PNG = 'png'

View File

@@ -1,11 +1,13 @@
""" """
Stegasoo Tests (v4.0.0) 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: Updated for v4.0.0:
- Same API as v3.2.0 (passphrase, no date_str) - 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) - Python 3.12 recommended (3.13 not supported)
""" """
@@ -16,17 +18,20 @@ import io
import stegasoo import stegasoo
from stegasoo import ( from stegasoo import (
generate_pin, generate_pin,
generate_phrase, generate_passphrase,
generate_credentials, generate_credentials,
validate_pin, validate_pin,
validate_message, validate_message,
validate_passphrase, validate_passphrase,
validate_channel_key,
encode, encode,
decode, decode,
decode_text, decode_text,
generate_channel_key,
get_channel_fingerprint,
__version__, __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 len(pin) == length
assert pin.isdigit() assert pin.isdigit()
def test_generate_phrase_default(self): def test_generate_passphrase_default(self):
"""Default phrase should have 4 words (v3.2.0 change).""" """Default passphrase should have 4 words (v3.2.0 change)."""
phrase = generate_phrase() phrase = generate_passphrase()
words = phrase.split() words = phrase.split()
assert len(words) == 4 # Changed from 3 in v3.1.x assert len(words) == 4 # Changed from 3 in v3.1.x
def test_generate_phrase_custom_length(self): def test_generate_passphrase_custom_length(self):
"""Phrase generation should work for custom lengths.""" """Passphrase generation should work for custom lengths."""
for length in [3, 4, 5, 6, 8, 12]: for length in [3, 4, 5, 6, 8, 12]:
phrase = generate_phrase(length) phrase = generate_passphrase(length)
words = phrase.split() words = phrase.split()
assert len(words) == length assert len(words) == length
@@ -287,19 +292,20 @@ class TestOutputFormat:
# ============================================================================= # =============================================================================
# Header Overhead Test (v3.2.0) # Header Overhead Test (v4.0.0)
# ============================================================================= # =============================================================================
class TestConstants: class TestConstants:
"""Tests for constants and configuration.""" """Tests for constants and configuration."""
def test_header_overhead_value(self): def test_header_overhead_value(self):
"""Header overhead should be 65 bytes (v3.2.0 fix).""" """Header overhead should be 66 bytes (v4.0.0: added flags byte)."""
assert HEADER_OVERHEAD == 65 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: class TestEncodeDecode:
@@ -474,8 +480,8 @@ class TestEncodeDecode:
assert decoded.message == message assert decoded.message == message
def test_filename_has_no_date(self, png_image): def test_filename_format(self, png_image):
"""v3.2.0: Output filename should not have date suffix.""" """Output filename should have random hex and date suffix."""
result = encode( result = encode(
message="Test", message="Test",
reference_photo=png_image, reference_photo=png_image,
@@ -483,10 +489,10 @@ class TestEncodeDecode:
passphrase="test phrase here now", passphrase="test phrase here now",
pin="123456" pin="123456"
) )
# Filename should be like "a1b2c3d4.png", not "a1b2c3d4_20251227.png" # Filename format: {random_hex}_{YYYYMMDD}.{ext}
# Check that there's no underscore followed by 8 digits # e.g., "a1b2c3d4_20251227.png"
import re 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", pin="123456",
date_str="2025-01-01" # Removed parameter 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
)