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)
![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

View File

@@ -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;
}

View File

@@ -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);
}

View File

@@ -264,9 +264,71 @@
<span class="text-warning small">(provide same factors used during encoding)</span>
</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="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>
<div class="input-group pin-input-container">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9" style="max-width: 180px;">
@@ -277,121 +339,43 @@
<div class="form-text">If PIN was used during encoding</div>
</div>
</div>
<div class="col-md-8 mb-3">
<div class="security-box">
<div class="security-box h-100">
<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>
<!-- 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>
<select class="form-select" name="channel_key" id="channelSelectDec">
<option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</option>
<option value="none">Public</option>
<option value="custom">Custom</option>
</select>
<!-- 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[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
</div>
{% endif %}
</div>
</div>
</div>
<!-- ================================================================
CHANNEL KEY (v4.0.0) - Deployment/Group Isolation
================================================================ -->
<div class="mb-4">
<div class="security-box">
<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"
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
<!-- Custom Channel Key Input (shown when Custom selected) -->
<div class="mb-4 d-none" id="channelCustomInputDec">
<div class="security-box">
<label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
<div class="input-group">
<input type="text" name="channel_key_custom" class="form-control font-monospace"
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}"
id="channelKeyInputDec">
</div>
</div>
</div>
</div>
<!-- ================================================================

View File

@@ -331,9 +331,71 @@
<span class="text-warning small">(provide at least one: PIN or RSA Key)</span>
</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="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>
<div class="input-group pin-input-container">
<input type="password" name="pin" class="form-control" id="pinInput" placeholder="••••••" maxlength="9" style="max-width: 180px;">
@@ -344,116 +406,39 @@
<div class="form-text">Static 6-9 digit PIN</div>
</div>
</div>
<div class="col-md-8 mb-3">
<div class="security-box">
<div class="security-box h-100">
<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>
<!-- 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>
<select class="form-select" name="channel_key" id="channelSelect">
<option value="auto" selected>Auto{% if channel_configured %} (Server Key){% endif %}</option>
<option value="none">Public</option>
<option value="custom">Custom</option>
</select>
<!-- 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[:4] }}-••••-···-••••-{{ channel_fingerprint[-4:] }}</code>
</div>
{% endif %}
</div>
</div>
</div>
<!-- ================================================================
CHANNEL KEY (v4.0.0) - Deployment/Group Isolation
================================================================ -->
<div class="mb-4">
<div class="security-box">
<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"
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
<!-- Custom Channel Key Input (shown when Custom selected) -->
<div class="mb-4 d-none" id="channelCustomInput">
<div class="security-box">
<label class="form-label"><i class="bi bi-key me-1"></i> Custom Channel Key</label>
<div class="input-group">
<input type="text" name="channel_key_custom" class="form-control font-monospace"
placeholder="XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX"
pattern="[A-Za-z0-9]{4}(-[A-Za-z0-9]{4}){7}"
id="channelKeyInput">
<button class="btn btn-outline-secondary" type="button" id="channelKeyGenerate" title="Generate random key">
@@ -464,7 +449,6 @@
Invalid format. Use: XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX-XXXX
</div>
</div>
</div>
</div>
<!-- Advanced Options (DCT sub-options only) -->

View File

@@ -25,10 +25,12 @@
<div class="d-flex align-items-center justify-content-between">
<div>
<i class="bi bi-shield-lock me-2"></i>
<strong>Private Channel Active</strong>
<span class="text-muted ms-2">Messages are isolated to this deployment</span>
<strong>Private Channel Mode</strong>
</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>
<code class="small">{{ channel_fingerprint }}</code>
</div>
</div>
{% endif %}

View File

@@ -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"

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:
- 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",

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.
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

View File

@@ -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)

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)
# 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'

View File

@@ -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
)